diff --git a/.gitignore b/.gitignore index 604486d..9625039 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ */node_modules/ */build/ -.claude .DS_Store *.tsbuildinfo .env @@ -10,5 +9,5 @@ node_modules/ *.env.* !.env.example !*.env.example -.claude -CLAUDE.md \ No newline at end of file +.codex +AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b34dab0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,158 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -**MAIN RULE:** DON'T MAKE UP ANYTHING. If unsure, verify via project documentation, MCP tools, web search, or ask for clarification. - -## Development Commands - -### Backend (from `backend/`) -```bash -npm run dev # Development server with hot reload (tsx watch) -npm run verify # Run typecheck + lint + tests (use before commits) -npm run typecheck # TypeScript check only -npm run lint # ESLint only -npm run test # Node test runner (tsx --test) -npm run build # Production build → dist/ - -# Database -npm run db:migrate # Run pending migrations -npm run db:seed # Run seeders -npm run db:reset # Drop all → migrate → seed (destroys data) -npm run start # migrate + seed + watch (dev startup) - -# VM Database Reset (requires platform credentials) -# 1. npm ci (install deps) -# 2. Get credentials: pm2 env 1 | grep DB_PASS && cat ~/executor/.env | grep DB_ -# 3. DB_HOST=... DB_NAME=... DB_USER=... DB_PASS=... npm run db:reset -# 4. pm2 restart backend-dev frontend-dev --update-env -``` - -### Frontend (from `frontend/`) -```bash -npm run dev # Vite dev server (port 3000) -npm run build # Typecheck + Vite production build -npm run typecheck # TypeScript check only -npm run lint # ESLint only -npm run test # Vitest unit tests -npm run test:e2e # Playwright smoke tests (no backend) -npm run test:e2e:content # Playwright tests (requires backend running) -npm run test:e2e:content -- --grep "accessibility" # Accessibility tests only -``` - -### Root-level -```bash -npm run build:production # Build both frontend and backend -npm run start:production # Start production backend (serves API + SPA) -``` - -### Docker (from `docker/`) -```bash -docker compose up --build # PostgreSQL + app on localhost:8080 -docker compose down -v # Stop and remove (including DB data) -``` - -### Quick Start (Dev Mode) - -Prerequisites: PostgreSQL running locally with database `schoolchain_dev` (user: `postgres`, password: `postgres`). - -```bash -# Terminal 1 - Backend (port 8080) -cd backend && npm run dev - -# Terminal 2 - Frontend (port 3000) -cd frontend && npm run dev -``` - -- Frontend: http://localhost:3000 -- Backend API: http://localhost:8080/api-docs/ - -Note: Use `npm run start` (not `npm run dev`) for first run to execute migrations and seeders. - -### Seed Users (one per role) - -Seeded by `backend/src/db/seeders/*` from `shared/constants/seed-fixtures.ts`. Passwords are -hardcoded in the seeder (see table below). - -| Email | Name | Role | Scope | Password | -|---|---|---|---|---| -| `admin@flatlogic.com` | Mr. Alex Morgan | `super_admin` | system | `flatlogicAdmin123!` | -| `system_admin@flatlogic.com` | Ms. Jordan Chen | `system_admin` | system | `flatlogicAdmin123!` | -| `owner@flatlogic.com` | Mrs. Patricia Hayes | `owner` | organization | `flatlogicUser123!` | -| `superintendent@flatlogic.com` | Dr. Michael Torres | `superintendent` | organization | `flatlogicUser123!` | -| `director@flatlogic.com` | Dr. Sarah Williams | `director` | campus | `flatlogicUser123!` | -| `office_manager@flatlogic.com` | Ms. Lisa Park | `office_manager` | campus | `flatlogicUser123!` | -| `teacher@flatlogic.com` | Mrs. Emily Johnson | `teacher` | campus | `flatlogicUser123!` | -| `support_staff@flatlogic.com` | Mr. Marcus Davis | `support_staff` | campus | `flatlogicUser123!` | -| `student@flatlogic.com` | Emma Clark | `student` | external | `flatlogicUser123!` | -| `guardian@flatlogic.com` | Mr. Robert Clark | `guardian` | external | `flatlogicUser123!` | - -All belong to the seeded company **Demo Academy**; campus-scoped/external users are on the **Tigers** campus. `super_admin`/`system_admin` carry `globalAccess` (no org/campus). - -## Architecture - -### Three-Layer Pattern (Both Frontend and Backend) - -**Backend** (`backend/src/`): -``` -API Layer → routes/*.ts, api/controllers/*.ts, middlewares/*.ts -Business Layer → services/*.ts (static class methods) -Data Layer → db/api/*.ts (repositories), db/models/*.ts (Sequelize) -``` - -**Frontend** (`frontend/src/`): -``` -View Layer → pages/, components/ -Business Layer → business// (hooks, mappers, selectors) -Data Layer → shared/api/*.ts, shared/types/*.ts -``` - -Import direction: `API → Business → Data`. Never skip layers. Cross-cutting code lives in `shared/` and can be imported by any layer. - -### Key Patterns - -**CRUD Modules**: Most entities use shared factories: -- `services/shared/crud-service.ts` → one-line service config -- `api/controllers/shared/crud-controller.ts` → one-line controller config -- `api/http/crud-router.ts` → one-line router config - -**Error Handling**: Throw `AppError` subclasses from `shared/errors/`. The terminal `error-handler` middleware formats responses. - -**Authentication**: Backend-owned HttpOnly cookie auth. Frontend uses `AuthContext` as a thin provider; refresh/retry logic in `shared/api/httpClient.ts`. - -**Import Aliases**: Use `@/*` for all imports (maps to `./src/*` in both projects). - -## Working Principles - -1. **Check docs first**: Read relevant docs before starting (see Documentation Entry Points below). **Before any new model/column or DB manipulation (migration/seed), consult `backend/docs/data-model-guide.md`** — reuse/wire an existing slice or reserved table instead of duplicating. -2. **Minimal changes**: Only update necessary files, prefer simple robust solutions -3. **TypeScript strict mode**: No `any` types, never disable linter/TypeScript, no type casting -4. **Concise comments**: Explain "why" not "what" -5. **No over-engineering**: Build for small SaaS. Simplest robust solution. No enterprise patterns unless required. -6. **Centralized exceptions only**: Use `AppError` subclasses from `shared/errors/` -7. **Avoid hardcoded constants**: Add to `backend/src/shared/constants/` or `frontend/src/shared/constants/` -8. **Documentation matters**: Update docs after each task; create docs for new modules -9. **Tests matter**: Update tests after each task; create tests for new functionality -10. **English only**: All documentation, comments, code, and content must be in English (except TODOs or internal plans) - -## Documentation Entry Points - -- Frontend architecture: `frontend/docs/frontend-architecture.md` -- Backend architecture: `backend/docs/backend-architecture.md` -- **Data model guide: `backend/docs/data-model-guide.md`** — model purpose, status, and reuse guidance. **Consult before creating any new model/column or doing DB work** (migration/seed) so existing built slices or reserved SIS tables are wired/reused instead of duplicated. -- Database schema (column reference): `backend/docs/database-schema.md` (regenerate after schema changes) -- **Backlog / remaining work: `docs/backlog.md`** — the single source for deferred work and open gaps (endpoint wiring, RBAC residuals, design-gated UIs, Phase 4/5 items, file/test/a11y). Consult it before starting work or closing gaps, and keep it updated. (The former sequenced integration plan is retired; its history is in git.) -- VM deployment: `docs/deployment-vm.md` -- Docker deployment: `docs/deployment-docker.md` - -## Tech Stack - -- **Backend**: Node 24, Express 5, Sequelize 6, TypeScript 6, PostgreSQL -- **Frontend**: React 19, Vite 8, TypeScript 6, Tailwind 4, React Query, Vitest, Playwright -- **Both**: ESM modules, strict TypeScript, path aliases (`@/*`) - -## MCP Servers Available - -- `github` - GitHub operations -- `chrome-devtools` - Browser DevTools -- `posthog` - Analytics (project: TRO Matcher) diff --git a/backend/docs/attendance_sessions.md b/backend/docs/attendance_sessions.md index 587a58d..0547f3f 100644 --- a/backend/docs/attendance_sessions.md +++ b/backend/docs/attendance_sessions.md @@ -67,14 +67,14 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): nullable). Associations: `belongsTo` organization, campus, class (`classes`, as `class`), -class_subject (`class_subjects`), taken_by (`staff`), createdBy/updatedBy (users); `hasMany` +class_subject (`class_subjects`), taken_by (`users`), createdBy/updatedBy (users); `hasMany` `attendance_records` as `attendance_records_attendance_session`. `findBy`/`GET /:id` eager-load `attendance_records_attendance_session`, organization, campus, class, class_subject, and taken_by in a single `Promise.all`. List filters (`AttendanceSessionsFilter`): `id`, `notes` (iLike), `session_dateRange`, `session_type`, `campus` (id or name, `|`-separated), `class` (id or name), `class_subject` -(id or status), `taken_by` (id or `employee_number`), `organization`, `createdAtRange`, plus +(id or status), `taken_by` (id or email), `organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. ## Behavior / Notes @@ -94,4 +94,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`, - `classes`, `class_subjects`, `campuses`, `staff`, `permissions.md`. + `classes`, `class_subjects`, `campuses`, `users`, `permissions.md`. diff --git a/backend/docs/audio-files.md b/backend/docs/audio-files.md index a9c1e98..72a0e9d 100644 --- a/backend/docs/audio-files.md +++ b/backend/docs/audio-files.md @@ -8,9 +8,9 @@ Workstream 13 — a flexible classroom-timer sound library. A row is one of thre `director` / `office_manager` / `teacher` add library entries; any campus staff (`director`, `office_manager`, `teacher`, `support_staff`) can pick one to play in the classroom timer. The existing **built-in timer sounds stay hardcoded global -defaults** for every organization — they are served from the (global) -`content_catalog` (`classroomTimerSounds`) and synthesized client-side, so they -are not duplicated here. New library entries are **campus-scoped**. +defaults** for every organization — their metadata lives in frontend static +constants and they are synthesized client-side, so they are not duplicated here. +New library entries are **campus-scoped**. The **"Generate"** button in the timer creates a `recipe` row: a JSON set of synthesis parameters played purely via the Web Audio API (no file, no network). @@ -28,9 +28,9 @@ visible to everyone. Exactly one of `url` / `recipe` is populated, matching `kind` (validated in the service). For a `file` row, the binary is uploaded first through the JWT-authenticated file -subsystem (`POST /api/file/upload/...`, with the Workstream 7 per-file ownership -check) and `url` references it. A `url` row holds an external link. A `recipe` -row never touches the file subsystem. +subsystem (`POST /api/file/upload/...`) and `url` references it. Downloads are +JWT-only after the customer decision to remove per-file ownership checks. A `url` +row holds an external link. A `recipe` row never touches the file subsystem. ## Routes (`/api/audio_files`) @@ -43,7 +43,7 @@ row never touches the file subsystem. ## Authorization -- `READ_AUDIO_FILES` — all four campus roles (director via full access). +- `READ_AUDIO_FILES` — seeded for the campus audio-library audience. - `MANAGE_AUDIO_FILES` — `director`, `office_manager`, `teacher` (not `support_staff`, who is read/play-only). @@ -58,7 +58,7 @@ hardcoded built-ins with the `audio_files` library and groups them by origin — **Built-in** / **Generated** / **Uploaded** — for clear structure. Playback branches by kind: `builtin` → `playBuiltInSound(id)`, `recipe` → `playRecipe(recipe)` (`business/classroom-timer/audio-recipe.ts`), `file`/`url` → -`new Audio(url)`. Managers (`canManageAudioFiles`) see a **Generate** button and +`new Audio(url)`. Users with `MANAGE_AUDIO_FILES` see a **Generate** button and a delete affordance on their own rows; global defaults are read-only. ## Tests @@ -66,20 +66,18 @@ a delete affordance on their own rows; global defaults are read-only. - **Unit** (`npm test`): `audio-access.test.ts` (visibility/management rules) and `shared/constants/audio-files.test.ts` (the `file`/`url`/`recipe` kinds + `isAudioFileKind`). -- **Frontend unit** (`vitest`): `business/audio-files/selectors.test.ts` - (`canManageAudioFiles`) and `generate.test.ts` (the local recipe stub shape). +- **Frontend unit** (`vitest`): `business/audio-files/generate.test.ts` (the + local recipe stub shape). - **Seeded e2e** (`frontend/tests/e2e/audio-files.seeded.e2e.ts`, `npm run test:e2e:content`): create/persist + same-campus read, `support_staff` read-only, and external-role lockout. ## Open / deferred -- **Binary `file` upload UI** — the typed upload client is still to build, and - the download check must record a `file` row (or exempt audio) first: today - `assertCanDownloadFile` denies any `privateUrl` with no tracked `file` row, and - the standalone `/file/upload/:table/:field` path does not create one. `recipe` - and external `url` rows are unaffected (no `/file/download`). +- **Binary `file` upload UI** — the typed upload client now exists, so the audio + upload affordance can be wired when desired. `recipe` and external `url` rows + are unaffected (no `/file/download`). - **AI generation** — swap the local `generateSoundRecipe` stub for a real model call once an AI key is available; the rest of the pipeline is unchanged. -- If platform-global audio rows are later added, relax the file-download - ownership check for null-organization files so the defaults stream to all. +- If platform-global audio rows are later added, keep deletion/editing restricted + to platform-owned rows; download itself is already JWT-only. diff --git a/backend/docs/auth-profile.md b/backend/docs/auth-profile.md index eaad3bf..2d90456 100644 --- a/backend/docs/auth-profile.md +++ b/backend/docs/auth-profile.md @@ -33,8 +33,8 @@ tokens, or raw Sequelize model objects. `src/db/api/auth_refresh_tokens.ts` (`AuthRefreshTokensDBApi`), `src/db/api/roles.ts` (`RolesDBApi`, used by the permission middleware). - Models: `src/db/models/users.ts`, `src/db/models/auth_refresh_tokens.ts`, - plus the `roles`, `permissions`, `organizations`, `staff`, and `campuses` - models joined for the profile. + plus the `roles`, `permissions`, `organizations`, and `campuses` models + joined for the profile. - Shared used: `shared/config` (auth/cookie/OAuth config), `shared/jwt` (`jwtSign`), `shared/constants/auth.ts`, `shared/constants/roles.ts` (role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`, @@ -90,13 +90,14 @@ in `src/auth/auth.ts`; the Google email comes from the typed is rejected by the strategy (`done(new Error(...))`). - Permission enforcement (`src/middlewares/check-permissions.ts`): - `checkPermissions(permission)` allows the request if any of: - 1. self-access bypass — `currentUser.id` equals `req.params.id` or - `req.body.id`; - 2. global-access bypass — the user's `app_role.globalAccess` is `true` - (the system-scope roles `super_admin` / `system_admin`), which pass any - permission; - 3. the user's `custom_permissions` include `permission`; - 4. the effective role's permissions include `permission`. The effective + 1. read-only self-access bypass — `currentUser.id` equals `req.params.id` + on a `GET` request; + 2. super-admin bypass — the user's role is `super_admin`, which bypasses + standard permission checks except personal workflow permissions listed in + `GLOBAL_BYPASS_EXCLUDED_PERMISSIONS`; + 3. the user's `custom_permissions_filter` does not exclude `permission`; + 4. the user's `custom_permissions` include `permission`; + 5. the effective role's permissions include `permission`. The effective role is the user's `app_role`, or the cached seeded `guest` role (`ROLE_NAMES.GUEST`) when there is no assigned role. The `guest` role is fetched once at module load and cached. @@ -113,7 +114,7 @@ in `src/auth/auth.ts`; the Google email comes from the typed - The profile is loaded for the authenticated user only (`UsersDBApi.findProfileById(currentUser.id)`), so it reflects that user's - own organization, role, staff profile, and campus. + own organization, role, and direct tenant scope. - `signup` accepts an `organizationId` and assigns it to the created user. - Tenant filtering for other entities is enforced elsewhere (CRUD repositories scope by `currentUser.organizationId`); the auth profile endpoints do not @@ -123,33 +124,34 @@ in `src/auth/auth.ts`; the Google email comes from the typed `AuthService.currentUserProfile` returns (built from `findProfileById`): -- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName` +- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName`, `phoneNumber` - `organizationId` - `organizations` — `OrganizationDto` `{ id, name }` or `null` - `app_role` — `RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name` - is one of the 11 first-class role names and `scope` is its scope - (`system` | `organization` | `campus` | `external` | `guest`); the frontend + is one of the first-class role names and `scope` is its scope + (`system` | `organization` | `school` | `campus` | `class` | `external` | + `guest`); the frontend derives the UI role from `app_role.name`. There is no separate `productRole`. -- `staffProfile` — `StaffProfileDto` - `{ id, employee_number, job_title, staff_type, status, organizationId, - campusId, userId }` or `null` (first row of `staff_user`) - `campus` — `CampusDto` `{ id, name, code }` or `null` -- `campusId` — the campus DTO id, else the staff profile `campusId`, else `null` -- `permissions` — de-duplicated string names from the role's permissions plus - the user's `custom_permissions` - -Note: the profile payload does not include a `phoneNumber` field -(`findProfileById` does not select it and `currentUserProfile` does not return -it). +- `campusId` — the user's direct campus scope id, else the campus DTO id, else `null` +- `permissions` — effective permission names: role permissions plus + `custom_permissions`, minus `custom_permissions_filter`. Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts` `ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by `db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the two system roles, `globalAccess: true`. The preset permission matrix grants -`owner` / `superintendent` / `director` every permission, `office_manager` / -`teacher` / `support_staff` read-only entity permissions, and `student` / -`guardian` / `guest` none; `super_admin` / `system_admin` need no rows (they -bypass via `globalAccess`). Per-user `custom_permissions` extend a user's grants. +`owner` / `superintendent` / `principal` / `director` every permission, +`registrar` / `office_manager` / `teacher` / `support_staff` read-only entity +permissions, and `student` / `guardian` / `guest` no entity CRUD permissions; +`guardian` still receives the parent-communication product permission. +`system_admin` also receives explicit role-permission rows and is processed like +every other permission-based role; `globalAccess` still gives it platform-wide +tenant reach. Only `super_admin` keeps the standard permission bypass. Personal +workflow permissions (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`) are +excluded from that bypass and must be explicitly present. Per-user +`custom_permissions` extend a user's grants; `custom_permissions_filter` +removes specific permissions from the role grant. Signup / signin behavior (`src/services/auth.ts`): @@ -179,7 +181,9 @@ Signup / signin behavior (`src/services/auth.ts`): ## Tests -None yet (no auth unit/e2e test under `backend/src`). +- `src/services/auth.test.ts` covers auth/profile service behavior. +- `src/api/controllers/auth.controller.test.ts` covers auth controller request + handling. ## Related diff --git a/backend/docs/backend-architecture.md b/backend/docs/backend-architecture.md index 5a7da71..3a14fcb 100644 --- a/backend/docs/backend-architecture.md +++ b/backend/docs/backend-architecture.md @@ -21,6 +21,8 @@ Location: `paramStr`). - `src/middlewares/` — `authenticate` (passport), `checkPermissions`, `csrf-origin`, `error-handler`, `upload`. +- `src/commands/` — CLI/maintenance entrypoints. Commands are API-layer + adapters: parse/run the operation, call BLL services, and own process output. Responsibilities: @@ -40,10 +42,9 @@ The API layer must not: Every `/api` route is JWT-authenticated at the mount (`authenticated = passport.authenticate('jwt', { session: false })`) **except** the intentionally public surface: - the `/api/auth/*` public endpoints (sign-in / refresh / sign-out, password reset, email verification, OAuth — the authenticated sub-routes such as `/me` apply passport per route); -- `GET /api/public/campuses`; -- `GET /api/public/content-catalog/:contentType`. +- `GET /api/public/campuses`. -No tenant-owned mutable data is exposed publicly. Authorization is then by permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions()` for page reads and the special actions (`READ_FRAME`, `READ_WALKTHROUGH`, `READ_ATTENDANCE`, `READ_PARENT_COMM`, `READ_INTERNAL_COMM`, `FILL_ATTENDANCE`, `TAKE_QUIZ` — names from `shared/constants/product-permissions.ts`, so `custom_permissions` can extend access), while the manager-only writes (FRAME/walkthrough/communications/content-catalog editing and the staff/attendance reports) stay gated in their services by role until a dedicated `MANAGE_*` permission exists. The `users` / `staff` / `organizations` write paths add the §3.3 relational policy. Both `POST /api/file/upload` and `GET /api/file/download` require JWT, the local file handlers reject path traversal, and download enforces a per-file tenant/ownership check (the file's owning organization must match the requester's unless they have global access; see `file.md`). +No tenant-owned mutable data is exposed publicly. Content catalog reads now use authenticated `GET /api/content-catalog/read/:contentType` so scoped content can resolve from the current user. Authorization is then by effective permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions()` for page reads and special actions (`READ_FRAME`, `READ_WALKTHROUGH`, `READ_ATTENDANCE`, `READ_PARENT_COMM`, `READ_INTERNAL_COMM`, `FILL_ATTENDANCE`, `TAKE_QUIZ` — names from `shared/constants/product-permissions.ts`). Service-level feature gates use dedicated permissions such as `MANAGE_FRAME`, `MANAGE_WALKTHROUGH`, `MANAGE_CONTENT_CATALOG`, and report-read permissions. `globalAccess` expands tenant reach to platform scope; only `super_admin` bypasses the standard management/page permission checks, and even that bypass excludes personal workflow permissions (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`), which still require explicit grants. The User Admin `custom_permissions` / `custom_permissions_filter` controls can therefore add or remove these feature grants for tenant users, including `system_admin`. The `users` / `organizations` write paths still add the §3.3 relational policy because hierarchy constraints cannot be expressed as flat permissions. Both `POST /api/file/upload` and `GET /api/file/download` require JWT, and the local file handlers reject path traversal; downloads are JWT-only after the customer decision to remove per-file ownership checks (see `file.md`). ## Layer 2: Business Logic (BLL) @@ -56,7 +57,7 @@ Location: Responsibilities: - Own workflows, transactions, and coordination across repositories. -- Apply tenant, role, campus, and permission rules. +- Apply tenant, scope, campus, and permission rules. - Map DB records to response DTOs; validate and normalize inputs. - Accept typed inputs and return typed values/DTOs. @@ -75,6 +76,7 @@ Location: - `src/db/api/` — one `*DBApi` class per entity (the repository layer). - `src/db/models/` — Sequelize models. - `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`. +- `src/db/reset.ts`, `src/db/umzug.ts`, and other DB operational helpers. - `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`, `CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`. @@ -151,10 +153,10 @@ Most modules are assembled from shared factories/helpers — keep them that way. `findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`, `autocompleteByField`). -- **Feature service (BLL)** = reuse shared helpers: tenant/role access in +- **Feature service (BLL)** = reuse shared helpers: tenant/scope access in `services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`, - `hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`, - `campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …); + `hasGlobalAccess`, `requireUserId`, `hasFeaturePermission`, + `scopeDimensionWhere`, `assertAuthenticatedTenantUser`, …); validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`, `requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts` (`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`; @@ -200,8 +202,17 @@ silently using insecure defaults. ## Enforcement & verification - `src/shared/architecture/import-boundaries.test.ts` enforces the import - direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL - edge cases and the one DAL→BLL leak are capped by ceilings that must not grow. + direction. Every production `.ts` file must be assigned to a layer (test files, + declarations, and `test-utils/` are excluded). The test resolves alias and + relative project imports, treats all `src/db/**` files as DAL, forbids + unapproved cross-layer edges, and verifies exact allowlists so stale exceptions + are removed instead of silently accumulating. +- Current exact architecture exceptions are: + - `auth/auth.ts -> @/db/api/users` and + `middlewares/check-permissions.ts -> @/db/api/roles` for API edge wiring. + - `services/auth.ts -> express`, `services/file.ts -> express`, and + `services/file.ts -> @/middlewares/upload` for remaining HTTP-in-BLL cases. + - `db/api/file.ts -> @/services/file` for the file-storage deletion bridge. - ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the already-clean invariants at lint time (API→DAL, model/DAL/shared purity). - `npm run typecheck`, `npm run lint`, `npm test` are the verification gates; diff --git a/backend/docs/campus-attendance.md b/backend/docs/campus-attendance.md index d635c48..aabcac2 100644 --- a/backend/docs/campus-attendance.md +++ b/backend/docs/campus-attendance.md @@ -25,13 +25,14 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_ ## Access Rules - All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`). -- Mutations (`PUT` config / summary) additionally require manage access (`assertCanManageCampusAttendance`): the user must hold one of `CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES` (`super_admin`, `system_admin`, `owner`, `superintendent`, `director`, `office_manager`). Global-access roles pass `hasRoleAccess`. -- Campus-key access (`assertCanAccessCampusKey`): tenant-wide roles (`CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` — super admin, admin, platform owner) may access any campus key. Other users may only access the campus key derived from their own profile (campus code/name, or staff profile campus code/name, normalized via `normalizeCampusKey`); a mismatch or missing campus key throws `ForbiddenError`. +- Mutations (`PUT` config / summary) additionally require `FILL_ATTENDANCE` (`assertCanManageCampusAttendance`). Global-access users still pass through the shared global-access permission bypass. +- Campus-key access (`assertCanAccessCampusKey`): organization/global active scope may access any campus key in the active organization; school scope may access campus keys under the active school; campus/class scope may access only the current campus key. A mismatch or missing campus key throws `ForbiddenError`. - The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`). +- Organization and school attendance entry still writes a campus-level summary. The aggregate screens choose a scoped campus and call the same `PUT /summaries/:campusKey/:date` endpoint; organization/school totals are read-time aggregates, not separate rows. ## Tenant Scope - Every read and write filters by `organizationId: requireOrganizationId(currentUser)`. -- `campusScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; tenant-wide roles with no requested key see all campus keys (no `campus_key` filter); other users are restricted to their own derived `campus_key`, and users with no derivable campus key are rejected with `ForbiddenError`. +- `campusKeyScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; organization/global active scope with no requested key sees all campus keys in the active organization; school scope with no requested key sees all campus keys under the active school; campus/class scope is restricted to the current campus key, and users with no derivable campus key are rejected with `ForbiddenError`. - On upsert, the existing-row lookup keys on `organizationId` + `campus_key` (config) or `organizationId` + `campus_key` + `attendance_date` (summary). ## Data Contract @@ -54,7 +55,8 @@ Per the customer decision (2026-06-11), the **source of truth for campus attenda **Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row. ## Tests -None yet (no `*.test.ts` under `backend/src` references this slice). +- `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation. +- `src/services/campus_attendance.test.ts` covers active campus drill-down access by resolving campus UUID to the stored campus key. ## Related - Frontend: `frontend/docs/campus-attendance-integration.md`. diff --git a/backend/docs/campuses.md b/backend/docs/campuses.md index 742dde2..4948622 100644 --- a/backend/docs/campuses.md +++ b/backend/docs/campuses.md @@ -74,7 +74,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): `createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`. Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy -(users); `hasMany` `staff_campus`, `classes_campus`, `timetables_campus`, +(users); `hasMany` `classes_campus`, `timetables_campus`, `attendance_sessions_campus`, `messages_campus`, `documents_campus` (all keyed on `campusId`, `constraints: false`). @@ -105,4 +105,4 @@ None yet. - Public read surface: `campus-catalog.md` (`GET /api/public/campuses`, shares the `src/db/models/campuses.ts` model but not the `src/db/api/campuses.ts` repository). - Generic-CRUD contract: `backend-architecture.md`. -- Related slices: `staff`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`. +- Related slices: `users`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`. diff --git a/backend/docs/class_subjects.md b/backend/docs/class_subjects.md index b55ac3c..980df85 100644 --- a/backend/docs/class_subjects.md +++ b/backend/docs/class_subjects.md @@ -3,7 +3,7 @@ ## Purpose `class_subjects` is the per-organization join between `classes` and `subjects` — it represents a -subject taught in a class, optionally assigned to a teacher (staff). It is a generic-CRUD slice +subject taught in a class, optionally assigned to a teacher user. It is a generic-CRUD slice assembled from the shared factories; the backend is the source of truth for these assignments. ## Slice Files (by layer) @@ -62,7 +62,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): - `importHash` (unique), `organizationId`, `classId`, `subjectId`, `teacherId`, `createdById`, `updatedById`, timestamps. -Associations: `belongsTo` organization, class (classes), subject (subjects), teacher (staff), +Associations: `belongsTo` organization, class (classes), subject (subjects), teacher (users), createdBy/updatedBy (users); `hasMany` `timetable_periods_class_subject`, `attendance_sessions_class_subject`, `assessments_class_subject`. `findBy`/`GET /:id` eager-load timetable_periods_class_subject, attendance_sessions_class_subject, assessments_class_subject, @@ -70,7 +70,7 @@ organization, class, subject and teacher in a single `Promise.all` (the class as exposed on the output as `class`). List filters (`ClassSubjectsFilter`): `id`, `class` (id or name, `|`-separated), `subject` (id or -name, `|`-separated), `teacher` (id or `employee_number`, `|`-separated), `status`, +name, `|`-separated), `teacher` (id or email, `|`-separated), `status`, `organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. ## Behavior / Notes @@ -90,4 +90,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `subjects`, - `staff`, `timetable_periods`, `attendance_sessions`, `assessments`, `permissions.md`. + `users`, `timetable_periods`, `attendance_sessions`, `assessments`, `permissions.md`. diff --git a/backend/docs/classes.md b/backend/docs/classes.md index a4c3bf1..9d053e0 100644 --- a/backend/docs/classes.md +++ b/backend/docs/classes.md @@ -63,14 +63,14 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): `homeroom_teacherId`, `createdById`, `updatedById`, timestamps. Associations: `belongsTo` organization, campus, academic_year (academic_years), grade (grades), -homeroom_teacher (staff), createdBy/updatedBy (users); `hasMany` `class_enrollments_class`, +homeroom_teacher (users), createdBy/updatedBy (users); `hasMany` `class_enrollments_class`, `class_subjects_class`, `attendance_sessions_class`. `findBy`/`GET /:id` eager-load class_enrollments_class, class_subjects_class, attendance_sessions_class, organization, campus, academic_year, grade and homeroom_teacher in a single `Promise.all`. List filters (`ClassesFilter`): `id`, `name` (ilike), `section` (ilike), `capacityRange`, `status`, `campus` (id or name, `|`-separated), `academic_year` (id or name, `|`-separated), -`grade` (id or name, `|`-separated), `homeroom_teacher` (id or `employee_number`, `|`-separated), +`grade` (id or name, `|`-separated), `homeroom_teacher` (id or email, `|`-separated), `organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. ## Behavior / Notes @@ -89,5 +89,5 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `class_enrollments`, - `class_subjects`, `campuses`, `academic_years`, `grades`, `staff`, `attendance_sessions`, + `class_subjects`, `campuses`, `academic_years`, `grades`, `users`, `attendance_sessions`, `permissions.md`. diff --git a/backend/docs/communications.md b/backend/docs/communications.md index 2544b0a..497ddb6 100644 --- a/backend/docs/communications.md +++ b/backend/docs/communications.md @@ -1,59 +1,79 @@ # Communications Backend ## Purpose -The communications slice exposes product-focused endpoints for parent messages and internal alert events, instead of the generated CRUD routes. Parent messages reuse the existing `messages` and `message_recipients` tables; internal alerts own the `communication_events` table. The backend is the source of truth for these records. +The communications area exposes two product-focused flows instead of generic CRUD: + +- Parent/guardian direct messages through `direct_messages`. +- Internal alert events through `communication_events`. ## Slice Files (by layer) -- Route: `src/routes/communications.ts` (thin wiring; `GET /parent-messages`, `POST /parent-messages`, `GET /events`, `POST /events`). Mounted at `/api/communications` behind the `authenticated` middleware in `src/index.ts`. -- Controller: `src/api/controllers/communications.controller.ts` (custom — `listParentMessages`, `createParentMessage`, `listEvents`, `createEvent`). +- Route: `src/routes/communications.ts` (thin wiring; `GET /events`, `POST /events`). Mounted at `/api/communications` behind the `authenticated` middleware in `src/index.ts`. +- Controller: `src/api/controllers/communications.controller.ts` (custom — `listEvents`, `createEvent`). - Service (BLL): `src/services/communications.ts` (+ `src/services/communications.types.ts`). Contains validation, scope resolution, and DTO mappers. -- Repository (DAL): queries run through `db.messages`, `db.message_recipients`, and `db.communication_events` inside the service (no separate `db/api` file). -- Models: `src/db/models/communication_events.ts`; plus the existing `src/db/models/messages.ts` and `src/db/models/message_recipients.ts` (used by the parent-message flow). -- Shared used: `db/with-transaction.ts`, `services/shared/access.ts`, `services/shared/validate.ts` (`nullableString`), `shared/object.ts` (`isRecord`), `shared/constants/communications.ts` (channels, audiences, statuses, recipient types, event-type values, parent-message categories, manager/tenant-wide role lists), `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. +- Repository (DAL): queries run through `db.communication_events` inside the service (no separate `db/api` file). +- Models: `src/db/models/communication_events.ts`. +- Shared used: `services/shared/access.ts`, `shared/constants/communications.ts` (event-type values and manager role list), `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. + +Direct messages: +- Route: `src/routes/direct_messages.ts`, mounted at `/api/direct_messages`, authenticated and gated by `READ_PARENT_COMM`. +- Controller: `src/api/controllers/direct_messages.controller.ts`. +- Service: `src/services/direct_messages.ts`. +- Model: `src/db/models/direct_messages.ts`. ## API All routes require JWT authentication. -- `GET /api/communications/parent-messages` -> `200` `{ rows, count }`. Optional query `category`; supports `limit`/`page`. -- `POST /api/communications/parent-messages` -> `201` the created parent-message DTO. Body is `req.body.data`. - `GET /api/communications/events` -> `200` `{ rows, count }`. Optional query `type`; supports `limit`/`page`. - `POST /api/communications/events` -> `201` the created event DTO. Body is `req.body.data`. +- `PATCH /api/communications/events/:id` -> `200` the updated event DTO. Body is `req.body.data`. +- `DELETE /api/communications/events/:id` -> `204`. Soft-deletes a wrongly-created alert without creating a user-facing notification. +- `POST /api/communications/events/:id/cancel` -> `201` the cancellation notification DTO. Body is `req.body.data` with optional `reason`. +- `GET /api/direct_messages/contacts` -> `200` `{ rows }`, contacts available through a shared student. +- `GET /api/direct_messages/conversations` -> `200` `{ rows }`, one row per `otherUserId + studentId`. +- `GET /api/direct_messages/thread/:otherUserId?studentId=:studentId` -> `200` the isolated thread for that staff/guardian/student context; marks incoming messages in that context as read. +- `POST /api/direct_messages/send` -> `200` the created message. Body is `req.body.data` with `recipientId`, `body`, and `studentId`. -Parent-message DTO fields: `id`, `text` (from `body`), `to` (first recipient `recipient_label`), `date` (ISO, from `sent_at` or `createdAt`), `category` (derived from `subject`), `sentAt`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. +Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event_type`), `targetLevel`, `roles`, `organizationId`, `schoolId`, `campusId`, `classId`, `canceledEventId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. -Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event_type`), `roles`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. +Direct-message contact/conversation rows include `conversationKey`, `userId`, `name`, `role`, `studentId`, and `studentName`. `conversationKey` is derived from `userId + studentId`; it is a client key, not a stored column. ## Access Rules -- All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`). -- `POST /events` additionally requires manage access (`assertCanManageCommunications`): the user must hold one of `COMMUNICATION_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`. -- Listing and creating parent messages requires only an authenticated tenant user. +- All endpoints require an authenticated user. Tenant-scoped alerts require a tenant context; platform-scope alerts are allowed for global-access managers. +- `POST /events` additionally requires `MANAGE_INTERNAL_COMM`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. +- `PATCH /events/:id`, `DELETE /events/:id`, and `POST /events/:id/cancel` are allowed for the original creator or a manager with `MANAGE_INTERNAL_COMM` in the alert's scope. Global admins can mutate tenant alerts from platform root, but they do not see other users' tenant alerts in the root list; they see them through tenant drill-down. +- Alert create accepts exact targets: `system`, `all`, `organization`, `school`, and `campus`. Class-level create/targeting is intentionally not supported; class-scope users read campus-level alerts. +- Global root managers can create `system`, `all`, and selected organization/school/campus alerts. Organization managers can target their own organization, schools, or campuses. School managers can target their own school or campuses. Campus managers can target their own campus only. +- Direct messages require `READ_PARENT_COMM`. The granted audience is `office_manager`, `teacher`, and `guardian`. +- Direct-message access is membership-based and student-context based: + - Guardians can find the teacher and office manager connected to each linked student. + - Teachers can find guardians for students in their class. + - Office managers can find guardians for students on their campus. + - Threads and sends are allowed only when the requested `studentId` matches one of those contact rows. - The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user. ## Tenant Scope -- `GET /parent-messages` filters by organization via `getOrganizationIdOrGlobal(currentUser)`: - global access users see messages across all organizations; regular users see only their own org. - Global access users also see all users' messages; regular users see only their own (`createdById`). - Audience is always `guardians`, plus `campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES)`. -- `GET /events` filters by organization via `getOrganizationIdOrGlobal(currentUser)` plus the same - `campusScope`. Global access users see events across all organizations. -- `campusScope` (from `services/shared/access.ts`): tenant-wide roles (`COMMUNICATION_TENANT_WIDE_ROLE_NAMES` — super admin, admin, platform owner, tenant director) or global access see all of the organization; other users are restricted to their profile `campusId` when one is present. -- On create, `organizationId` and `campusId` are derived from the user (`getOrganizationIdOrGlobal`, `getCampusId`). +- Internal alerts are stored with an exact target. `targetLevel = system` with a null tenant chain is visible only in the platform/system scope. `targetLevel = all` with a null tenant chain is a platform-wide broadcast. +- List visibility includes the current scope and descendant targets. Platform-root users see platform alerts plus tenant-target alerts they created. When they drill into a tenant, they see that tenant scope like a scoped viewer. Organization users see their organization alerts plus school/campus alerts inside that organization. School users see their school alerts plus campus alerts inside that school. Campus/class users see their campus alerts only. +- Parent alerts do not automatically propagate down: an organization alert is not a campus alert unless the sender also targets that campus. Class-scope users read campus-level alerts because class content is campus-level. +- Selecting multiple tenant audiences creates multiple `communication_events` rows with the same title/date/type and different exact target stamps. +- Direct messages are not tenant-broadcast records. Contacts and threads are resolved through the current user's student links, class, or campus. ## Data Contract -- Parent message input (`ParentMessageInput`): `recipientName` (required non-empty string), `messageText` (required non-empty string), `category` (optional; mapped to one of `behavior`, `event`, `progress`, `general`, defaulting to `general`). -- Event input (`EventInput`): `title` (required), `date` (required, stored to `event_date`), `type` (required; must be one of `meeting`, `drill`, `event`, `deadline`), `roles` (optional array of role names; an empty/missing array defaults to `['teacher','support_staff','office_manager','director']`; invalid values throw `ValidationError`). -- `communication_events` model: `title` (text), `event_date` (DATEONLY), `event_type` (ENUM of the four types), `roles` (JSONB, default `[]`), `organizationId` (not null), nullable `campusId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, `belongsTo` associations to `organizations`, `campuses`, `users` (createdBy, updatedBy). +- Event input (`EventInput`): `title` (required), `date` (required, stored to `event_date`), `type` (required; must be one of `meeting`, `drill`, `event`, `deadline`), `targets` (optional array of `{ level, id }`; omitted targets default to the creator's exact scope), `roles` (legacy metadata; it is not used for visibility). +- `communication_events` model: `title` (text), `event_date` (DATEONLY), `event_type` (ENUM of the four types), `targetLevel` (text: `system`, `all`, `organization`, `school`, `campus`), `roles` (JSONB, default `[]`), nullable `organizationId`, nullable `schoolId`, nullable `campusId`, nullable `classId`, nullable `canceledEventId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, `belongsTo` associations to `organizations`, `campuses`, `users` (createdBy, updatedBy). - List pagination: both lists use `resolvePagination(limit, page)`. ## Behavior / Notes -- `createParentMessage` runs inside `withTransaction`: creates a `messages` row (`subject = category`, `body = messageText`, `channel = in_app`, `audience = guardians`, `sent_at = now`, `status = sent`) and a matching `message_recipients` row (`recipient_type = guardian`, `recipient_label = recipientName`, `delivery_status = sent`, `delivered_at = now`), then re-reads the message with its recipient to build the DTO. -- Parent-message list includes `message_recipients` (alias `message_recipients_message`, only `recipient_label`) and orders by `sent_at desc`, then `createdAt desc`. -- Event list orders by `event_date asc`, then `createdAt desc`. `createEvent` is a single create (no transaction). +- Direct-message conversations are separated by student. The same guardian and teacher can have separate threads for two different students because reads/writes filter by `sender/recipient pair + studentId`. +- Event list orders by `event_date asc`, then `createdAt desc`. `createEvent` creates one row per exact target. `cancelEvent` creates a new cancellation notification with the same target stamp and soft-deletes the original alert; `deleteEvent` only soft-deletes the original alert. - Validation failures throw `ValidationError`; access failures throw `ForbiddenError`. ## Tests -None yet (no `*.test.ts` under `backend/src` references this slice). +- `src/services/direct_messages.test.ts` covers contact discovery through linked + students, guardian/staff contact visibility, student-separated conversations, + ambiguous same-counterpart thread rejection, and persisted `studentId` + context when sending. ## Related - Frontend: `frontend/docs/communications-integration.md`. -- Related backend slice: content catalog (`backend/docs/content-catalog.md`) backs safety protocols and parent-message templates referenced by the communications UI. +- Related backend slice: direct messages (`src/services/direct_messages.ts`) backs the guardian/staff Messages UI. diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index f25ee03..fd32f64 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -1,23 +1,21 @@ # Content Catalog Backend ## Purpose -`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for these domain/content records, instead of duplicating them in frontend runtime constants. A public read endpoint serves the active payload for a content type, and authenticated management endpoints allow runtime configuration of catalog records. +`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for editable/scoped domain/content records. Product-static catalogs such as personality quiz content and classroom-timer presets live in frontend constants instead. Authenticated reads return the active payload scoped to the current user, and authenticated management endpoints allow runtime configuration of catalog records. ## Slice Files (by layer) - Routes: - - `src/routes/public_content_catalog.ts` — public read (`GET /:contentType`). Mounted at `/api/public/content-catalog` in `src/index.ts` (NOT behind the `authenticated` middleware). - - `src/routes/content_catalog.ts` — management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware. + - `src/routes/content_catalog.ts` — authenticated read (`GET /read/:contentType`) plus management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware. - Controllers: - - `src/api/controllers/public_content_catalog.controller.ts` (`findByType`). - - `src/api/controllers/content_catalog.controller.ts` (`list`, `create`, `findManagedByType`, `update`, `remove`). + - `src/api/controllers/content_catalog.controller.ts` (`readByType`, `list`, `create`, `findManagedByType`, `update`, `remove`). - Service (BLL): `src/services/content_catalog.ts` (single `ContentCatalogService`: `list`, `findByType`, `findManagedByType`, `create`, `update`, `delete`). - Repository (DAL): queries run through `db.content_catalog` inside the service (no separate `db/api` file). - Model: `src/db/models/content_catalog.ts` (no model associations). -- Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasRoleAccess`), `shared/constants/content-catalog.ts` (`CONTENT_CATALOG_MANAGER_ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. +- Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasFeaturePermission`, tenant helpers), `shared/constants/content-catalog.ts`, `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. - Seeds: `src/db/seeders/20260608103000-content-catalog.ts` with payloads in `src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`. ## API -- `GET /api/public/content-catalog/:contentType` -> `200` the active content DTO for that `contentType`. No JWT required. Throws `ValidationError('contentCatalogNotFound')` when no active record exists. +- `GET /api/content-catalog/read/:contentType` -> `200` the active content DTO for that `contentType`, scoped to the authenticated user. Throws `ValidationError('contentCatalogNotFound')` when no active record exists. - `GET /api/content-catalog` -> `200` `{ rows, count }`. JWT + manage access. Supports `limit`/`page`. - `POST /api/content-catalog` -> `201` the created DTO. JWT + manage access. Body is `req.body.data`. - `GET /api/content-catalog/:contentType` -> `200` the active DTO for that type. JWT + manage access (delegates to `findByType`). @@ -27,35 +25,44 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`. ## Access Rules -- The public read endpoint (`/api/public/content-catalog/:contentType`) is unauthenticated and applies no role check; it only returns records where `active = true`. -- All `/api/content-catalog` management endpoints require manage access (`assertCanManageContentCatalog`): the user must hold one of `CONTENT_CATALOG_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`. (`hasRoleAccess` is the only gate; there is no separate `assertAuthenticatedTenantUser` call in this service.) +- `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. ## Tenant Scope -None. `content_catalog` has no `organizationId`/`campusId` columns and the service applies no tenant or campus filtering; records are global. `content_type` is unique across the table. +Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: + +- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level. +- School-scoped types read the caller's resolved school row. +- Org-scoped types read the caller's organization row. +- Shared/global types use all-null tenant ids. + +Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors. ## Data Contract - Create input: `content_type` (required non-empty string), `payload` (required; any non-`undefined` JSON value), optional `active` (defaults to `true` unless explicitly `false`), optional `importHash`. - Update input: `payload` (required), optional `active` (set to `true` unless explicitly `false`). -- Model fields: `id` (UUID), `content_type` (text, unique, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`. +- Model fields: `id` (UUID), `content_type` (text, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), nullable tenant ids (`organizationId`, `schoolId`, `campusId`, `classId`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`. - List pagination: `list` uses `resolvePagination(limit, page)` and orders by `content_type asc`. ## Behavior / Notes -- `create` looks up any existing row by `content_type` with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created. +- `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created. - `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`). - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. - 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`, `parent-message-templates`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `personality-quiz-questions`, `personality-types`, `personality-workplace-content`, `esa-funding-content`, `safety-protocols`, `classroom-timer-backgrounds`, `classroom-timer-sounds`, `classroom-timer-presets`, `classroom-timer-tips`, `personality-quiz-features`. +The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`. + +The 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. ### Content authoring rules -- Add production content records to backend seed payloads, not frontend constants. -- Frontend constants stay limited to UI config, labels, query keys, timing values, and presentation tokens. +- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. +- Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs. - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. ## Tests -None yet (no `*.test.ts` under `backend/src` references this slice). +Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. -- Related backend slice: communications (`backend/docs/communications.md`) — its UI consumes `parent-message-templates` and `safety-protocols` from this catalog. +- Related backend slice: communications (`backend/docs/communications.md`) covers internal alerts and direct guardian/staff messages. diff --git a/backend/docs/cookie-auth.md b/backend/docs/cookie-auth.md index d3921d8..e6a89b3 100644 --- a/backend/docs/cookie-auth.md +++ b/backend/docs/cookie-auth.md @@ -161,7 +161,7 @@ grace window: ```bash npm run db:cleanup-tokens # dev (tsx) -node dist/db/cleanup-refresh-tokens.js # prod (built; or npm run db:cleanup-tokens:prod) +node dist/commands/cleanup-refresh-tokens.js # prod (built; or npm run db:cleanup-tokens:prod) ``` - **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The diff --git a/backend/docs/data-model-guide.md b/backend/docs/data-model-guide.md index 63889fb..3b806e6 100644 --- a/backend/docs/data-model-guide.md +++ b/backend/docs/data-model-guide.md @@ -26,8 +26,8 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`) | Campus daily attendance (working `/attendance`) | **`campus_attendance_config` + `campus_attendance_summaries`** | manual **aggregate** entry by `office_manager` (`FILL_ATTENDANCE`). NOT per-student rows. | | Staff attendance | **`staff_attendance_records`** | the staff-attendance slice. | | 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 **and** the daily zone check-in (`item_id` = campus-local date). | -| Backend-owned editable content | **`content_catalog`** | global JSONB payloads by `content_type`. | +| 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. | | File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. | ## Reserved SIS cluster — kept but **not yet wired** @@ -43,7 +43,7 @@ a coherent academic/SIS graph: - **`grades`** — grade **levels** (Grade 1, K…: `name`, `code`, `sort_order`). NOT marks. A `class` belongs to a grade. - **`subjects`** — the reusable **subject catalog** (Math, English: `name`, `code`, `description`). - **`class_subjects`** — a **subject taught in a class by a teacher** (`classId` + `subjectId` + `teacherId`). The many-to-many junction class↔subject; `assessments`, `attendance_sessions`, and `timetable_periods` hang off it. (So `subjects` = "what"; `class_subjects` = "this offering".) -- **`classes`** — a class/group (`name`, `section`, `capacity`, `grade`, `homeroom_teacher`→`staff`, `academic_year`, `campus`). The grouping unit relating teachers (via `class_subjects`), students (via `class_enrollments`), and guardians (via their student). +- **`classes`** — a class/group (`name`, `section`, `capacity`, `grade`, `homeroom_teacher`→`users`, `academic_year`, `campus`). The grouping unit relating teachers (via `class_subjects`), students (via `class_enrollments`), and guardians (via their student). - **`class_enrollments`** — **student↔class membership** (`classId` + `studentId`, `enrolled_on`, `ended_on`, `status`). ### Assessments (header/detail pair) @@ -54,7 +54,7 @@ a coherent academic/SIS graph: ### Attendance (header/detail pair — student-level) -- **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by`→`staff`, `class`/`class_subject`). +- **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by`→`users`, `class`/`class_subject`). - **`attendance_records`** — a **student's status** in a session (`status` present/absent/late, `minutes_late`) per `studentId`. - Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses the **aggregate** `campus_attendance_*` instead, and staff use `staff_attendance_records`. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision. @@ -65,7 +65,7 @@ a coherent academic/SIS graph: ### People -- **`staff`** — the **employment/HR profile** of a user (`employee_number`, `job_title`, `staff_type`, `hire_date`, `status`, `campus`), linked by `userId`. Distinct from `users` (the **account/identity**: login, email, role, password). One user ↔ one staff profile; students/guardians are users **without** a staff record. Already used by the staff-management and staff-attendance slices. +- **`users`** — the account, identity, role, and scope. Do not add a separate employment profile table; employees, students, and guardians are all users with roles and scope columns. Staff classification is derived from `roles.name` plus the user's scope columns. ## Pruned — do NOT re-add diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index 4273db3..02d4c34 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -21,7 +21,7 @@ Types below are the SQL column types. A few Sequelize types are returned as JS ` ## Domains - **Tenancy & Access:** `organizations`, `users`, `roles`, `permissions` -- **Campuses & People:** `campuses`, `staff` +- **Campuses & People:** `campuses`, `users` - **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` @@ -46,7 +46,7 @@ erDiagram campuses ||--o{ attendance_sessions : "campus" classes ||--o{ attendance_sessions : "class" class_subjects ||--o{ attendance_sessions : "class_subject" - staff ||--o{ attendance_sessions : "taken_by" + users ||--o{ attendance_sessions : "taken_by" users ||--o{ auth_refresh_tokens : "user" organizations ||--o{ auth_refresh_tokens : "organization" organizations ||--o{ campus_attendance_config : "organization" @@ -59,12 +59,12 @@ erDiagram organizations ||--o{ class_subjects : "organization" classes ||--o{ class_subjects : "class" subjects ||--o{ class_subjects : "subject" - staff ||--o{ class_subjects : "teacher" + users ||--o{ class_subjects : "teacher" organizations ||--o{ classes : "organization" campuses ||--o{ classes : "campus" academic_years ||--o{ classes : "academic_year" grades ||--o{ classes : "grade" - staff ||--o{ classes : "homeroom_teacher" + users ||--o{ classes : "homeroom_teacher" organizations ||--o{ communication_events : "organization" campuses ||--o{ communication_events : "campus" organizations ||--o{ frame_entries : "organization" @@ -81,9 +81,6 @@ erDiagram organizations ||--o{ safety_quiz_results : "organization" campuses ||--o{ safety_quiz_results : "campus" users ||--o{ safety_quiz_results : "user" - organizations ||--o{ staff : "organization" - campuses ||--o{ staff : "campus" - users ||--o{ staff : "user" organizations ||--o{ staff_attendance_records : "organization" campuses ||--o{ staff_attendance_records : "campus" users ||--o{ staff_attendance_records : "user" @@ -136,7 +133,6 @@ _Relations:_ - **has many** `academic_years` as `academic_years_organization` (FK `organizationId`) - **has many** `grades` as `grades_organization` (FK `organizationId`) - **has many** `subjects` as `subjects_organization` (FK `organizationId`) -- **has many** `staff` as `staff_organization` (FK `organizationId`) - **has many** `classes` as `classes_organization` (FK `organizationId`) - **has many** `class_enrollments` as `class_enrollments_organization` (FK `organizationId`) - **has many** `class_subjects` as `class_subjects_organization` (FK `organizationId`) @@ -169,7 +165,6 @@ Authentication identities. `email` is required (login + primary contact). Belong | `passwordResetToken` | text | yes | — | | | `passwordResetTokenExpiresAt` | timestamptz | yes | — | | | `provider` | text | yes | — | | -| `importHash` | varchar | yes | — | unique, audit | | `organizationId` | uuid | yes | — | FK | | `createdById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit | @@ -183,7 +178,6 @@ _Relations:_ - **many-to-many with** `permissions` as `custom_permissions` (FK `users_custom_permissionsId`) - **many-to-many with** `permissions` as `custom_permissions_filter` (FK `users_custom_permissionsId`) -- **has many** `staff` as `staff_user` (FK `userId`) - **has many** `messages` as `messages_sent_by` (FK `sent_byId`) - **belongs to** `roles` as `app_role` (FK `app_roleId`) - **belongs to** `organizations` as `organizations` (FK `organizationId`) @@ -192,7 +186,7 @@ _Relations:_ #### `roles` -Named permission sets (RBAC), the 11 first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles). +Named permission sets (RBAC), the first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles). | Column | Type | Null | Default | Notes | |---|---|---|---|---| @@ -262,45 +256,12 @@ A physical or online campus belonging to one organization. Parent of students, s _Relations:_ -- **has many** `staff` as `staff_campus` (FK `campusId`) - **has many** `classes` as `classes_campus` (FK `campusId`) - **has many** `timetables` as `timetables_campus` (FK `campusId`) - **has many** `attendance_sessions` as `attendance_sessions_campus` (FK `campusId`) - **has many** `messages` as `messages_campus` (FK `campusId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) -#### `staff` - -Staff members, optionally linked to a `user` account; can be homeroom teacher, subject teacher, attendance taker, payment receiver. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `employee_number` | text | yes | — | | -| `job_title` | text | yes | — | | -| `staff_type` | enum | yes | — | | -| `hire_date` | timestamptz | yes | — | | -| `status` | enum | yes | — | | -| `importHash` | varchar | yes | — | unique, audit | -| `createdAt` | timestamptz | yes | — | audit | -| `updatedAt` | timestamptz | yes | — | audit | -| `deletedAt` | timestamptz | yes | — | audit | -| `campusId` | uuid | yes | — | FK | -| `organizationId` | uuid | yes | — | FK | -| `userId` | uuid | yes | — | FK | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **has many** `classes` as `classes_homeroom_teacher` (FK `homeroom_teacherId`) -- **has many** `class_subjects` as `class_subjects_teacher` (FK `teacherId`) -- **has many** `attendance_sessions` as `attendance_sessions_taken_by` (FK `taken_byId`) -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `campuses` as `campus` (FK `campusId`) -- **belongs to** `users` as `user` (FK `userId`) -- **has many** `file` as `photo` (FK `belongsToId`) - ### Academics #### `academic_years` @@ -407,7 +368,7 @@ _Relations:_ - **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) - **belongs to** `grades` as `grade` (FK `gradeId`) -- **belongs to** `staff` as `homeroom_teacher` (FK `homeroom_teacherId`) +- **belongs to** `users` as `homeroom_teacher` (FK `homeroom_teacherId`) #### `class_enrollments` @@ -461,7 +422,7 @@ _Relations:_ - **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `classes` as `class` (FK `classId`) - **belongs to** `subjects` as `subject` (FK `subjectId`) -- **belongs to** `staff` as `teacher` (FK `teacherId`) +- **belongs to** `users` as `teacher` (FK `teacherId`) #### `timetables` @@ -577,7 +538,7 @@ _Relations:_ #### `attendance_sessions` -An attendance session for a class/class_subject taken by a staff member. +An attendance session for a class/class_subject taken by a user. | Column | Type | Null | Default | Notes | |---|---|---|---|---| @@ -604,7 +565,7 @@ _Relations:_ - **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `classes` as `class` (FK `classId`) - **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`) -- **belongs to** `staff` as `taken_by` (FK `taken_byId`) +- **belongs to** `users` as `taken_by` (FK `taken_byId`) #### `attendance_records` @@ -647,6 +608,8 @@ Product-module config for campus attendance. | `deletedAt` | timestamptz | yes | — | audit | | `organizationId` | uuid | yes | — | FK | | `campusId` | uuid | yes | — | FK | +| `schoolId` | uuid | yes | — | exact-scope owner | +| `classId` | uuid | yes | — | exact-scope owner | | `createdById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit | @@ -783,13 +746,17 @@ Product-module communication events (meetings, drills, events, deadlines). | `title` | text | no | — | | | `event_date` | date | no | — | | | `event_type` | text | no | — | | +| `targetLevel` | text | no | campus | exact alert audience: system/all/organization/school/campus | | `roles` | jsonb | no | Array | | | `importHash` | varchar | yes | — | unique, audit | | `createdAt` | timestamptz | yes | — | audit | | `updatedAt` | timestamptz | yes | — | audit | | `deletedAt` | timestamptz | yes | — | audit | | `organizationId` | uuid | yes | — | FK | +| `schoolId` | uuid | yes | — | FK | | `campusId` | uuid | yes | — | FK | +| `classId` | uuid | yes | — | reserved; class alerts are not created | +| `canceledEventId` | uuid | yes | — | original alert id when this row is a cancellation notification | | `createdById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit | @@ -807,9 +774,13 @@ Product content catalog. | Column | Type | Null | Default | Notes | |---|---|---|---|---| | `id` | uuid | no | UUIDV4 | PK | -| `content_type` | text | no | — | unique | +| `content_type` | text | no | — | content key; tenant-scoped types can have one row per owner | | `payload` | jsonb | no | — | | | `active` | boolean | no | true | | +| `organizationId` | uuid | yes | — | exact tenant owner for org/school/campus-scoped content | +| `schoolId` | uuid | yes | — | exact tenant owner for school-scoped content | +| `campusId` | uuid | yes | — | exact tenant owner for campus-scoped content | +| `classId` | uuid | yes | — | reserved exact tenant owner; class roles currently read campus content | | `importHash` | varchar | yes | — | unique, audit | | `createdAt` | timestamptz | yes | — | audit | | `updatedAt` | timestamptz | yes | — | audit | @@ -847,7 +818,7 @@ _Relations:_ #### `user_progress` -Per-user progress (sign learning, zone check-ins). +Per-user progress (sign learning, zone check-ins, classroom strategy favorites). | Column | Type | Null | Default | Notes | |---|---|---|---|---| @@ -1103,4 +1074,3 @@ _Relations:_ - **belongs to** `users` as `user` (FK `userId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) - diff --git a/backend/docs/file.md b/backend/docs/file.md index a3266cc..daf24aa 100644 --- a/backend/docs/file.md +++ b/backend/docs/file.md @@ -19,10 +19,9 @@ contract the reference frontend used (preserved here so it isn't lost): download URL is `${API_BASE_URL}/file/download?privateUrl=` (works for both the local-disk dev backend and the GCloud prod backend). -**Open blocker:** `assertCanDownloadFile` denies any `privateUrl` with no tracked -`file` row, but the standalone `/file/upload/:table/:field` path does not create -one — so a non-global user would 403 on download until the upload flow also -records a `file` row (or the path is exempted). See `audio-files.md`. +Downloads are JWT-only by customer decision. The standalone `/file/upload/:table/:field` +path can therefore serve uploaded logos/avatars/files even when it does not create a +tracked `file` row. ## Slice Files (by layer) @@ -31,12 +30,9 @@ records a `file` row (or the path is exempted). See `audio-files.md`. calls `assertCanDownloadFile` before serving. - Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`, `downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus - `src/services/file-access.ts` (`assertCanDownloadFile`) for the per-file authorization. Both - upload and download require JWT; local handlers reject path traversal. Download enforces a - per-file tenant/ownership check: the file's owning organization (resolved from its `privateUrl` - via the uploader `createdById`) must match the requester's organization, unless the requester - has global access; files with no tracked row are denied. (Upload-side per-file ownership and a - typed frontend upload client are still open — tracked in the file workstream.) + `src/services/file-access.ts` (`assertCanDownloadFile`) for download authorization. Both + upload and download require JWT; local handlers reject path traversal. Download no longer + enforces per-file tenant ownership. - Repository (DAL): `src/db/api/file.ts` (`FileDBApi` — `replaceRelationFiles`, `_addFiles`, `_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports `@/services/file` to call `deleteGCloud` (see Behavior / Notes). @@ -48,12 +44,9 @@ records a `file` row (or the path is exempted). See `audio-files.md`. ## API - `GET /api/file/download?privateUrl=` -> downloads the file. **Requires JWT authentication** - (`passport.authenticate('jwt')`) **and per-file ownership**: `assertCanDownloadFile` resolves the - file's owning organization (via `FileDBApi.findOwnerOrganizationIdByPrivateUrl`, which reads the - uploader `createdById`) and returns `403` (`ForbiddenError`) unless the requester has global - access or shares that organization; an untracked `privateUrl` is also `403`. The controller then - dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or `NEXT_PUBLIC_BACK_API` is set, - otherwise `downloadLocal`. + (`passport.authenticate('jwt')`). `assertCanDownloadFile` verifies that a current user exists, + then the controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or + `NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`. - Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download` from `config.uploadDir`. @@ -76,8 +69,8 @@ records a `file` row (or the path is exempted). See `audio-files.md`. rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied (`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not exercised from this endpoint. -- Download has no authentication middleware and performs no ownership check; access is governed - solely by knowing the `privateUrl`. +- Download requires a valid JWT and performs no per-file ownership check; access is governed by + authentication plus knowledge of the `privateUrl`. ## Tenant Scope @@ -109,8 +102,8 @@ otherwise `ValidationError('iam.errors.fileNameRequired')` is raised. (`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for `services/file.ts` and `services/auth.ts`. - `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy - files), which is a DAL→BLL dependency. The same import-boundaries test caps DAL→BLL violations at - one, and this file is the allowed one. + files), which is a DAL→BLL dependency. The same import-boundaries test keeps this as an exact + allowlisted exception; any additional DAL→BLL import fails the architecture test. - `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows for inputs marked `new`. @@ -119,7 +112,7 @@ otherwise `ValidationError('iam.errors.fileNameRequired')` is raised. No dedicated `file` unit/e2e test exists. The architecture test `src/shared/architecture/import-boundaries.test.ts` references this slice by name in its -BLL→HTTP and DAL→BLL debt-ceiling assertions. +exact BLL→HTTP and DAL→BLL exception allowlists. ## Related diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md index 0722844..efa0b54 100644 --- a/backend/docs/frame-entries.md +++ b/backend/docs/frame-entries.md @@ -14,8 +14,8 @@ source of truth for persisted FRAME data; the frontend never substitutes static `db/api/frame_entries.ts`). - Model: `src/db/models/frame_entries.ts`. - Shared used: `db/with-transaction.ts` (`withTransaction`), `services/shared/access.ts` - (`getOrganizationIdOrGlobal`, `hasRoleAccess`), `shared/constants/pagination.ts` (`resolvePagination`), - `shared/constants/frame.ts` (`FRAME_EDITOR_ROLE_NAMES`), `shared/errors/*` + (`getOrganizationIdOrGlobal`, `hasFeaturePermission`), `shared/constants/pagination.ts` (`resolvePagination`), + `shared/errors/*` (`ForbiddenError`, `ValidationError`). ## API @@ -33,18 +33,16 @@ Request body for create/update is wrapped as `{ data: }`. - Read: any authenticated user in the organization, or any user with `globalAccess` (sees all organizations). -- Edit (create/update): restricted to roles in `FRAME_EDITOR_ROLE_NAMES` (director/superintendent - capabilities) — `super_admin`, `system_admin`, `owner`, `superintendent`, - `director`. Enforced by `assertCanEdit` via `hasRoleAccess`; a non-editor gets - `ForbiddenError`. Frontend may hide editing controls, but the backend check is authoritative. +- Edit (create/update/delete): requires `MANAGE_FRAME`. Role-seeded permissions + are only the baseline grants. Per-user `custom_permissions` can grant it and + `custom_permissions_filter` can remove it for non-global users. ## Tenant Scope - Organization is resolved via `getOrganizationIdOrGlobal`: users with `globalAccess` bypass the org filter and see/create entries across all organizations; regular users are bound to their organization. -- `campusId` is optional; when omitted it defaults to the current staff profile's campus - (`currentUser.staff_user[0].campusId`) when available, else `null`. +- `campusId` is optional; when omitted it defaults to the current user's direct campus scope when available, else `null`. ## Data Contract @@ -76,4 +74,4 @@ free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null ## Related - Frontend: `frontend/docs/frame-integration.md`. -- Related slices: `user-progress.md` (dashboard zone check-ins), `staff` (campus resolution). +- Related slices: `user-progress.md` (dashboard zone check-ins), `users` (campus resolution). diff --git a/backend/docs/index.md b/backend/docs/index.md index 0748e9d..88b44c0 100644 --- a/backend/docs/index.md +++ b/backend/docs/index.md @@ -2,7 +2,7 @@ ## Start Here -- Repository working rules: [`../../CLAUDE.md`](../../CLAUDE.md) +- Repository working rules: [`../../AGENTS.md`](../../AGENTS.md) - Backend architecture: [`backend-architecture.md`](backend-architecture.md) - Database schema: [`database-schema.md`](database-schema.md) - Error handling: [`error-handling.md`](error-handling.md) @@ -30,7 +30,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related). - [`auth-profile.md`](auth-profile.md): sign-in, profile, `GET /api/auth/me`, OAuth, permission model. - [`cookie-auth.md`](cookie-auth.md): HttpOnly cookie sessions and refresh rotation. - [`permissions.md`](permissions.md): the `${METHOD}_${ENTITY}` permission catalog and enforcement. -- [`roles.md`](roles.md): the 11 first-class roles (scope, globalAccess) and role<->permission linkage. +- [`roles.md`](roles.md): first-class roles (scope, globalAccess) and role<->permission linkage. - [`users.md`](users.md): users entity, invitations, role policy, provisioning, and CSV bulk import. ## Product Feature Slices @@ -54,7 +54,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related). One document per entity (assembled from the shared CRUD factories; identical 9-endpoint surface — see [`shared-crud-factories.md`](shared-crud-factories.md)). -- People: [`staff.md`](staff.md), [`organizations.md`](organizations.md). +- People: [`users.md`](users.md), [`organizations.md`](organizations.md). - Academics: [`classes.md`](classes.md), [`subjects.md`](subjects.md), [`class_subjects.md`](class_subjects.md), [`class_enrollments.md`](class_enrollments.md), [`academic_years.md`](academic_years.md), [`assessments.md`](assessments.md), diff --git a/backend/docs/migrations-and-seeders.md b/backend/docs/migrations-and-seeders.md index 9ffb1e2..d5e9a85 100644 --- a/backend/docs/migrations-and-seeders.md +++ b/backend/docs/migrations-and-seeders.md @@ -24,11 +24,14 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o `20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts` (the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date). -- Seeders: `src/db/seeders/*.ts` — `admin-user` (the 10 per-role RBAC fixture users), - `user-roles` (the 11 first-class roles, the permission catalog incl. product-feature +- Seeders: `src/db/seeders/*.ts` — `admin-user` (the system users, the primary + tenant's per-role users, and the secondary tenant's per-role users from + `shared/constants/seed-fixtures.ts`), + `user-roles` (the first-class roles, the permission catalog incl. product-feature permissions, the role->permission matrix, role assignment by user id), `product-campuses`, `content-catalog` (+ payloads under `seeders/content-catalog-data/`), `rbac-fixtures` - (the company, campus->org ownership, per-user org/campus links, staff profiles), and + (the two companies, school/campus ownership, per-user org/school/campus links, + and user employment fields), `class-fixtures` (one class, enrollment, and guardian link per tenant), and `20260611050000-policy-documents-seed.ts` (3 safety protocols + 4 handbook policies). Shared fixture definitions live in `src/shared/constants/seed-fixtures.ts`. @@ -65,7 +68,10 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o ## Tests -None yet. +- `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant + user topology and credential uniqueness. +- `src/db/seeders/user-roles.test.ts` covers the seeded product-permission + contract for parent communication and registrar report/audit grants. ## Related diff --git a/backend/docs/organizations.md b/backend/docs/organizations.md index 5798aa6..65529ac 100644 --- a/backend/docs/organizations.md +++ b/backend/docs/organizations.md @@ -99,4 +99,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; tenant scoping: `permissions.md`. Every - per-organization slice references this table via `organizationId` (e.g. `staff`, `campuses`). + per-organization slice references this table via `organizationId` (e.g. `users`, `campuses`). diff --git a/backend/docs/permissions.md b/backend/docs/permissions.md index caab50b..7278f3f 100644 --- a/backend/docs/permissions.md +++ b/backend/docs/permissions.md @@ -64,9 +64,13 @@ then delegates to `checkPermissions(permissionName)`. 1. Self-access bypass: read-only — a `GET` whose `currentUser.id === req.params.id` (the `req.body.id` bypass was removed; profile self-edits go through `/api/auth/profile`). -2. Custom (per-user) permissions: the current user's `custom_permissions` contains a record whose +2. Global-access bypass: the current user's `app_role.globalAccess` is true, except for personal + workflow permissions listed in `GLOBAL_BYPASS_EXCLUDED_PERMISSIONS`. +3. Custom permission filter: a non-global user's `custom_permissions_filter` can deny a permission + that would otherwise come from the role grant. +4. Custom (per-user) permissions: the current user's `custom_permissions` contains a record whose `name` equals the required permission. -3. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise +5. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise the `guest` role (`ROLE_NAMES.GUEST`), which is fetched once at module load via `RolesDBApi.findBy` and cached (`publicRoleCache`); if the cache is empty it is fetched synchronously as a fallback. `resolveRolePermissions` reads the role's permission names from an @@ -82,16 +86,17 @@ Error via `next(new Error(...))`. Besides the `${METHOD}_${ENTITY}` CRUD permissions, the catalog includes product-feature permissions defined once in `shared/constants/product-permissions.ts`: a `READ_` per -product page and the three action permissions `FILL_ATTENDANCE` / `TAKE_QUIZ` / `ACK_READ_RECEIPT`. -The role seeder grants them per role (full-access roles get all; campus staff get their page set; -external roles get the external pages). The feature routes enforce them with the **same** +product page plus action/report/manage permissions such as `FILL_ATTENDANCE`, `TAKE_QUIZ`, +`ACK_READ_RECEIPT`, `ACK_POLICY`, `ZONE_CHECKIN`, `MANAGE_*`, and report reads. +The role seeder grants baseline permission sets; `custom_permissions` and +`custom_permissions_filter` then adjust individual users. The feature routes enforce them with the **same** `checkPermissions(name)` middleware: page reads and the special actions call it directly (e.g. `GET /api/frame_entries` → `READ_FRAME`, `PUT /api/campus_attendance/summaries/...` → `FILL_ATTENDANCE`, `POST /api/safety_quiz_results` → `TAKE_QUIZ`). Because that middleware honors -`custom_permissions` (step 2 above), a director can extend a single user's feature access by +`custom_permissions` (step 4 above), a manager can extend a single user's feature access by granting one of these names. Manager-only writes (FRAME/walkthrough/communications/content-catalog -editing, the staff/attendance reports) remain gated by role inside their services until dedicated -`MANAGE_*` permissions are introduced. +editing, audio files, and staff/attendance reports) use dedicated `MANAGE_*` or report-read +permissions rather than role-name guards. ## Tenant Scope diff --git a/backend/docs/personality-quiz-results.md b/backend/docs/personality-quiz-results.md index 7392f70..962ec46 100644 --- a/backend/docs/personality-quiz-results.md +++ b/backend/docs/personality-quiz-results.md @@ -5,7 +5,7 @@ `personality_quiz_results` stores each authenticated tenant user's current personality quiz result (one row per user per organization) and exposes an aggregate distribution of personality types for leadership reporting. The backend owns tenant scope, user ownership, the saved -personality type, and the answer snapshot. It does not write to staff profile records. +personality type, and the answer snapshot. It does not write to user employment fields. ## Slice Files (by layer) @@ -18,8 +18,8 @@ personality type, and the answer snapshot. It does not write to staff profile re separate `db/api/personality_quiz_results.ts`). - Model: `src/db/models/personality_quiz_results.ts`. - Shared used: `db/with-transaction.ts` (`withTransaction`); `services/shared/access.ts` - (`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasRoleAccess`); - `shared/constants/personality.ts` (`PERSONALITY_REPORT_ROLE_NAMES`); `shared/errors/*` + (`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasFeaturePermission`); + `shared/errors/*` (`ForbiddenError`, `ValidationError`). ## API @@ -30,7 +30,8 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu (most recently updated), or `null` if none exists. - `PUT /api/personality_quiz_results/me` -> `200`. Request body wrapped as `{ data: { personality_type, quiz_answers } }`. Creates or updates the current user's result and - returns the saved DTO. + returns the saved DTO. If the caller is a parent-scope user acting through a drilled child scope, + the request is accepted as a no-op and returns the caller's currently saved result (or `null`). - `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns `{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report roles. @@ -40,18 +41,24 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu - `getCurrentUserResult` / `upsertCurrentUserResult`: any authenticated tenant user (`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by `userId`). -- `distribution`: restricted to `PERSONALITY_REPORT_ROLE_NAMES` (`super_admin`, - `system_admin`, `owner`, `superintendent`, `director`) or any role with - `globalAccess`, enforced by `hasRoleAccess`; otherwise `ForbiddenError`. The distribution - response contains only `type` and `count` per group — no individual names or answers. +- `upsertCurrentUserResult` persists only when the active scope is the user's own scope. Parent + users drilled into a child school/campus/classroom can complete the UI flow there, but the backend + does not create or update reportable quiz rows for that child scope. +- `distribution`: restricted to `READ_PERSONALITY_REPORTS`; otherwise + `ForbiddenError`. Role-seeded permissions are only the baseline grants. The distribution response contains + only `type` and `count` per group — no individual names or answers. + `custom_permissions` can grant the report permission and + `custom_permissions_filter` can remove it for non-global users. ## Tenant Scope - Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org filter and can see their results across organizations; regular users are bound to their org. -- On upsert, `campusId` is set from `getCampusId` (the current staff profile's campus, else the +- On upsert, `campusId` is set from `getCampusId` (the current user's direct campus, else the user's `campusId`, else `null`); `userId`, `createdById`, `updatedById` come from the current user. +- Drilled child scopes are not treated as the user's own scope for personal saves, even though reads + and reports use the active scope for visibility. - `distribution` filters by organization via `getOrganizationIdOrGlobal` (global users see all orgs) and, when a `campusId` query value is provided, additionally by that campus. @@ -72,7 +79,9 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu ## Behavior / Notes -- `upsertCurrentUserResult` runs inside `withTransaction`: it looks up the existing row by +- `upsertCurrentUserResult` first checks whether the caller is acting in their own scope. If not, + it skips persistence and returns the current saved result. Otherwise it runs inside + `withTransaction`: it looks up the existing row by `organizationId` + `userId` and updates it, otherwise creates a new one. - `getCurrentUserResult` orders by `updatedAt` desc and returns the first match. - `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc; @@ -80,7 +89,8 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu ## Tests -None yet (no `personality_quiz_results` unit/e2e test in `src/`). +- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child + scopes do not create or update personality quiz rows. ## Related diff --git a/backend/docs/policy-documents.md b/backend/docs/policy-documents.md index 7512467..e23b15c 100644 --- a/backend/docs/policy-documents.md +++ b/backend/docs/policy-documents.md @@ -6,8 +6,8 @@ Workstream 11 — persistence of staff acknowledgment of policy/safety documents Campus staff must acknowledge two categories of documents — **Safety Protocols** (official/government) and **Handbook & Policies** (internal). `director` and -`office_manager` author the documents; all four campus staff roles (`director`, -`office_manager`, `teacher`, `support_staff`) acknowledge them. Acknowledgment is +`office_manager` author the documents; users with explicit `ACK_POLICY` +acknowledge them. Acknowledgment is **per document version**: editing a document bumps its `version`, which requires re-acknowledgment. @@ -18,9 +18,8 @@ entity it replaced has been removed): - **Handbook & Policies** (`business/policies`) lists `policy_documents` of `category = handbook_policy`, mapping the handbook's sub-category to/from `tag` - (`toPolicyViewModel` / `toPolicyDocumentMutationDto`). Management is gated to - owner/superintendent/director/office_manager (`canManagePolicies`, mirroring the - backend grant). Acknowledgment is **persisted** via `policy_acknowledgments` + (`toPolicyViewModel` / `toPolicyDocumentMutationDto`). Management affordances + are gated by effective policy-document permissions. Acknowledgment is **persisted** via `policy_acknowledgments` (`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former local-state set. - **Safety Protocols** (`business/safety-protocols`) consumes @@ -35,9 +34,12 @@ entity it replaced has been removed): **dynamic** `steps` + `autismConsiderations` rows that add/remove independently, so each protocol carries its own count (`useSafetyProtocolsModule` + `SafetyProtocolForm` / - `SafetyDynamicListEditor`; gated by `canManageSafetyProtocols`, which reuses - the policy grant). Title/body/steps/considerations changes bump `version` and + `SafetyDynamicListEditor`; gated by effective policy-document permissions). + Title/body/steps/considerations changes bump `version` and require re-acknowledgment. +- **Acknowledgments** (`business/policies`, `pages/modules/AcknowledgmentsPage`) + renders the manager report from `GET /api/policy_acknowledgments/report`. + The Director Dashboard also shows the report summary as an overview card. ## Entities @@ -68,8 +70,12 @@ entity it replaced has been removed): `checkCrudPermissions('policy_documents')` (`${METHOD}_POLICY_DOCUMENTS`). - `GET /api/policy_acknowledgments` (the caller's own acknowledgments) and `POST /api/policy_acknowledgments` (`{ data: { policyDocumentId } }` → - acknowledges the document's **current** version) — both guarded by + acknowledges the document's **current** version, or returns `null` as a no-op when the caller is + acting through a drilled child scope that is not their own scope) — both guarded by `checkPermissions('ACK_POLICY')`. +- `GET /api/policy_acknowledgments/report` — manager-facing acknowledgment + report for the current tenant scope. Returns summary totals, per-document + completion rows, and per-staff document statuses. ## Authorization @@ -79,13 +85,26 @@ entity it replaced has been removed): - `CREATE/UPDATE/DELETE_POLICY_DOCUMENTS` — `director` (full access) and `office_manager` (explicit grant in the role seeder). `teacher`/`support_staff` are read-only. -- `ACK_POLICY` — the four campus roles (a product-feature action permission; - extendable per user via `custom_permissions`). +- `ACK_POLICY` — seeded for `director`, `office_manager`, `teacher`, and + `support_staff`. It is a personal workflow permission, is not implied by + `globalAccess`, and can be extended or removed per user via effective + permissions. Acknowledgments persist only when the active scope is the user's own scope; parent + users drilled into a child school/campus/classroom do not see personal acknowledgment badges or + acknowledgment actions there, and the backend no-ops any attempted write so no reportable rows are + created for the child scope. Tenant/campus scoping is applied in the data layer (`tenantWhere` / `findOwnedByPk`); acknowledgment reads are additionally restricted to the -caller's own `userId`. A manager-facing acknowledgment-status report (audience -TBD) is a deferred refinement. +caller's own `userId`. The manager report is scoped to the current tenant: +organization for owner/superintendent, school for principal/registrar, campus +for director, and the active drilled scope for platform admins. Report access +requires `READ_POLICY_ACKNOWLEDGMENT_REPORTS`; owner, superintendent, +principal, registrar, and director receive it through seeded baseline permissions. +super_admin/system_admin can read it only while drilled into a tenant. +`custom_permissions` can grant the report permission to tenant users and +`custom_permissions_filter` can remove it. The report population is active +staff accounts in the current scope holding one of +director/office_manager/teacher/support_staff roles. ## Tests @@ -93,6 +112,8 @@ TBD) is a deferred refinement. `users.test.ts`, `npm test`): the pure domain rules — `isPolicyDocumentCategory` validation, the `nextPolicyDocumentVersion` re-acknowledgment bump, and `formatPersonName` (author rendering). +- **Backend service** (`backend/src/services/policy_acknowledgments.test.ts`): + acknowledgment listing plus the drilled-child no-op rule for parent users. - **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook; tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps + autism considerations) and `business/safety-protocols/selectors.test.ts` @@ -105,7 +126,4 @@ TBD) is a deferred refinement. ## Open / deferred -- Acknowledgment-status reporting for managers (who-acknowledged-what) — pending - the report-audience decision. -- The acknowledgment + document-management **UI** is design-gated (see - `docs/backlog.md`). +None. diff --git a/backend/docs/roles.md b/backend/docs/roles.md index 32a5261..c84e6fa 100644 --- a/backend/docs/roles.md +++ b/backend/docs/roles.md @@ -106,10 +106,11 @@ is `createdAt desc`. `name ASC` and selects only `id`/`name`. - Note: `RolesFilter` accepts an `active` flag and `findAll` filters on an `active` column the `roles` model does not declare; it is currently inert (kept for source accuracy). -- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the 11 first-class roles from +- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the first-class roles from `ROLE_DEFINITIONS` (`shared/constants/roles.ts`), each with its `scope`. `globalAccess: true` is set - for the two system-scope roles (`super_admin`, `system_admin`); their requests bypass both - per-permission checks (`check-permissions.ts`) and the `organizationId` filter. Org/campus roles + for the two system-scope roles (`super_admin`, `system_admin`) so their tenant reach is platform-wide. + `system_admin` still resolves permissions from explicit role rows like the tenant roles; only + `super_admin` bypasses the standard per-permission checks (`check-permissions.ts`). Org/campus roles (`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained to their tenant/campus by scoping; `student`, `guardian`, and the unauthenticated-fallback `guest` have no entity-CRUD permissions. The seeder also assigns roles to the seeded users and writes the @@ -118,7 +119,10 @@ is `createdAt desc`. ## Tests -None yet. +- `src/db/seeders/user-roles.test.ts` covers role-seeder product permission + grants that are part of the role contract. +- `src/services/shared/role-policy.test.ts` covers target-role management + hierarchy rules. ## Related diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md index c9f35d2..a56726e 100644 --- a/backend/docs/safety-quiz-results.md +++ b/backend/docs/safety-quiz-results.md @@ -17,9 +17,8 @@ role snapshot, and persistence. Each submission is an append (create) — there - Model: `src/db/models/safety_quiz_results.ts`. - Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`, - `assertAuthenticatedTenantUser`, `hasRoleAccess`, `getDisplayName`); `shared/constants/roles.ts` - (`ROLE_NAMES`); `shared/constants/safety-quiz.ts` - (`SAFETY_QUIZ_REPORT_ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`). + `assertAuthenticatedTenantUser`, `hasFeaturePermission`, `getDisplayName`); + `shared/constants/roles.ts` (`ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`). ## API @@ -29,17 +28,22 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re `limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user (see Access Rules), ordered by `completed_at` desc. - `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: }`. - Returns the created result DTO. + Returns the created result DTO. If the caller is a parent-scope user acting through a drilled + child scope, the request is accepted as a no-op and returns `null`. ## Access Rules - All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`). - `create`: a staff user creates a result for themselves; ownership fields are filled from the authenticated user. -- `list`: users holding a role in `SAFETY_QUIZ_REPORT_ROLE_NAMES` (`super_admin`, - `system_admin`, `owner`, `superintendent`, `director`) or any role with - `globalAccess` (via `hasRoleAccess`) see all org-level results; everyone else sees only their own - rows (filtered by `userId`). +- `create` persists only when the active scope is the user's own scope. Parent users drilled into a + child school/campus/classroom can complete the quiz there, but the backend does not create + reportable quiz rows for that child scope. +- `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results; + everyone else sees only their own rows (filtered by `userId`). + Role-seeded permissions are only the baseline grants. `custom_permissions` can + grant the report permission and + `custom_permissions_filter` can remove it for non-global users. ## Tenant Scope @@ -47,6 +51,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re filter and see results across all organizations; regular users are bound to their organization. - On create, `campusId` is set from `getCampusId`; `userId`, `createdById`, `updatedById` come from the current user. +- Drilled child scopes are not treated as the user's own scope for personal saves, even though list + visibility can use the active scope for reports. ## Data Contract @@ -69,12 +75,15 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re ## Behavior / Notes -- `create` runs inside `withTransaction`; trimmed string fields are persisted. +- `create` first checks whether the caller is acting in their own scope. If not, it skips + persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields + are persisted. - `list` is paginated with shared defaults (`resolvePagination`). ## Tests -None yet (no `safety_quiz_results` unit/e2e test in `src/`). +- `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child + scopes do not create safety quiz result rows. ## Related diff --git a/backend/docs/search.md b/backend/docs/search.md index e2a9352..6058cc2 100644 --- a/backend/docs/search.md +++ b/backend/docs/search.md @@ -51,7 +51,7 @@ The searched tables and columns are fixed in the service: - Text columns (`TABLE_COLUMNS`): `users` (firstName, lastName, phoneNumber, email); `organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name); `grades` (name, code, description); `subjects` (name, code, description); - `staff` (employee_number, job_title); `classes` (name, section); `timetables` + `users` (firstName, lastName, phoneNumber, email); `classes` (name, section); `timetables` (name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks); `assessments` (name, instructions); `assessment_results` (remarks); `messages` (subject, body); `message_recipients` (recipient_label, destination). diff --git a/backend/docs/shared-crud-factories.md b/backend/docs/shared-crud-factories.md index 3e0ce35..6606052 100644 --- a/backend/docs/shared-crud-factories.md +++ b/backend/docs/shared-crud-factories.md @@ -114,17 +114,18 @@ Generic over `Model`; cover the methods that are byte-identical across entities, ### Access helpers (`src/services/shared/access.ts`) -- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleName(currentUser?)` - — resolve scope/role from the current user. +- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleScope(currentUser?)` + — resolve tenant/scope from the current user. - `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`. - `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id or throw `ForbiddenError`. - `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user has both an id and an organization. -- `hasRoleAccess(currentUser, roleNames)` — `true` for `globalAccess` users or those - holding one of `roleNames`. -- `campusScope(currentUser, tenantWideRoleNames)` — returns `{}` for tenant-wide/global - users, else `{ campusId }` restricting to the user's campus. +- `hasFeaturePermission(currentUser, permission)` — resolves effective + permissions, including `custom_permissions`, `custom_permissions_filter`, and + the `globalAccess` exclusions for personal workflow permissions. +- `scopeDimensionWhere(currentUser, model)` — derives school/campus/class + constraints from the active scope. ### Validation helpers (`src/services/shared/validate.ts`) diff --git a/backend/docs/staff-attendance.md b/backend/docs/staff-attendance.md index 6eb3f39..0fdaed8 100644 --- a/backend/docs/staff-attendance.md +++ b/backend/docs/staff-attendance.md @@ -2,27 +2,27 @@ ## Purpose -`staff_attendance_records` stores staff-level attendance entries per organization. This slice is a -read-only reporting surface: it exposes a filtered record list and an aggregated summary used by the -attendance snapshot and the director dashboard. It does not write, import, or generate records. +`staff_attendance_records` stores staff-level attendance entries per organization. This slice exposes +a filtered record list, an aggregated summary used by the attendance snapshot, and a scoped upsert +endpoint for manual office/staff attendance entry. This is distinct from the student-level attendance models (`attendance_sessions`, `attendance_records`) and from campus daily aggregates (`campus_attendance_summaries`). ## Slice Files (by layer) -- Route: `src/routes/staff_attendance.ts` (thin wiring; `GET /records`, `GET /summary`). +- Route: `src/routes/staff_attendance.ts` (thin wiring; `GET /records`, `GET /summary`, + `PUT /records/:userId/:date`). - Controller: `src/api/controllers/staff_attendance.controller.ts` (custom — not the CRUD factory). - Service (BLL): `src/services/staff_attendance.ts`. -- Repository (DAL): queries run through `db.staff_attendance_records` and `db.staff` inside the +- Repository (DAL): queries run through `db.staff_attendance_records` and `db.users` inside the service (no separate `db/api/staff_attendance.ts`). - Model: `src/db/models/staff_attendance_records.ts`. - Shared used: `services/shared/access.ts` (`assertAuthenticatedTenantUser`, `requireOrganizationId`, - `requireUserId`, `hasRoleAccess`, `campusScope`), `services/shared/validate.ts` (`clampLimit`, + `requireUserId`, `hasFeaturePermission`, `campusDimensionScope`, `getRoleScope`, `getSchoolId`), `services/shared/validate.ts` (`clampLimit`, `optionalIsoDate`), `shared/constants/staff-attendance.ts` - (`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_REPORT_ROLE_NAMES`, - `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`, - `STAFF_ATTENDANCE_MAX_LIMIT`), `shared/constants/staff.ts` (`STAFF_STATUSES`). + (`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`, + `STAFF_ATTENDANCE_MAX_LIMIT`). ## API @@ -38,6 +38,9 @@ The slice is mounted at `/api/staff_attendance`; all routes require JWT authenti `{ staffCount, recordsCount, present, late, absent }`. Accepts the same `startDate`, `endDate`, and `limit` query parameters; `limit` is read from the filter type but the summary counts are computed with SQL `COUNT` aggregates, not by limiting rows. +- `PUT /api/staff_attendance/records/:userId/:date` -> `200` record DTO. Body: + `{ data: { status, note } }`, where `status` is `present`, `late`, or `absent`, and `note` is + nullable/optional text. The route requires `FILL_ATTENDANCE`. Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` raises `ValidationError`. @@ -46,16 +49,17 @@ Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` Enforced by `visibilityScope` in the service: -- A user who does NOT hold a report role (`STAFF_ATTENDANCE_REPORT_ROLE_NAMES`) sees only their own +- A user who does NOT have `READ_STAFF_ATTENDANCE_REPORTS` sees only their own records, scoped by `userId` (`requireUserId`). -- A user who holds a report role sees campus-scoped records via `campusScope`: tenant-wide roles - (`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`) or users with `globalAccess` see all organization - records; other report-role users are restricted to their own campus (`campusId` from their staff - profile, else unrestricted if no campus resolves). -- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles super_admin, system_admin, - owner, superintendent, director. -- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = super_admin, system_admin, owner. -- `globalAccess` on the user's app role grants access in any role check (`hasRoleAccess`). +- A user with `READ_STAFF_ATTENDANCE_REPORTS` sees scope-filtered records: + organization-wide for owner/superintendent, school campuses plus users directly + assigned to that school for principal/registrar, and a single campus for + director/campus scope. +- A user with `FILL_ATTENDANCE` can upsert staff attendance only for staff users inside their + effective scope: organization office users at organization scope, school office users at school + scope, or campus users at campus/class scope. +- Role-seeded permissions are only the baseline grants. `custom_permissions` can grant permissions + and `custom_permissions_filter` can remove them for non-global users. Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or resolvable organization raises `ForbiddenError`. @@ -63,9 +67,11 @@ resolvable organization raises `ForbiddenError`. ## Tenant Scope - Every query is bound to the current user's organization (`requireOrganizationId`). -- Within the organization, visibility is narrowed to the user, their campus, or the whole tenant per - the access rules above. The summary's `staffCount` query applies the same `campusScope` over the - `staff` table (active staff only). +- Within the organization, visibility is narrowed to the user, their campus, their school, or the + whole tenant per the access rules above. The summary's `staffCount` query applies the same scope + over internal-role users. For school scope, it includes users with `schoolId` equal to the active + school as well as users assigned to campuses under that school; for organization scope, it includes + organization office, school, and campus staff. ## Data Contract @@ -73,9 +79,8 @@ Record DTO returned by `GET /records` (`toRecordDto`): `id`, `date` (from `atten `status`, `note`, `user_name`, `user_role`, `organizationId`, `campusId`, `userId`, `createdAt`, `updatedAt`. -Summary DTO returned by `GET /summary`: `staffCount` (active staff in scope, from the `staff` table -filtered by `STAFF_STATUSES.ACTIVE`), `recordsCount` (= `present + late + absent`), `present`, `late`, -`absent`. +Summary DTO returned by `GET /summary`: `staffCount` (internal-role users in scope), `recordsCount` +(= `present + late + absent`), `present`, `late`, `absent`. Model `staff_attendance_records` fields: `id` (UUID PK), `attendance_date` (DATEONLY, not null), `status` (ENUM of `STAFF_ATTENDANCE_STATUSES` — `present`, `late`, `absent`; not null), `note` @@ -97,11 +102,13 @@ Associations (`belongsTo`): `organization`, `campus`, `user`, `createdBy`, `upda ## Tests -None yet (no `staff_attendance` unit/e2e test in `src/`). +- `src/services/staff_attendance.test.ts` verifies school report scope includes both school-owned + users and campuses under the school, and that school-scope office attendance upserts are scoped to + school office users. ## Related - Frontend: `frontend/docs/staff-attendance-integration.md`. - Related slices: `campus-attendance` (campus daily aggregates), the student-level - `attendance_sessions` / `attendance_records` slices, and `staff` (active staff count, campus + `attendance_sessions` / `attendance_records` slices, and `users` (active employee count, campus resolution). diff --git a/backend/docs/staff.md b/backend/docs/staff.md deleted file mode 100644 index 3868e4d..0000000 --- a/backend/docs/staff.md +++ /dev/null @@ -1,96 +0,0 @@ -# Staff Backend - -## Purpose - -`staff` is the per-organization employee profile roster, linking an optional `user` account and -`campus` to each staff member. It is a generic-CRUD slice assembled from the shared factories; -the backend is the source of truth for staff records. - -## Slice Files (by layer) - -- Route: `src/routes/staff.ts` — `createCrudRouter(controller, { permission: 'staff' })`. -- Controller: `src/api/controllers/staff.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/staff.ts` — `createCrudService(DbApi, { notFoundCode: 'staffNotFound' })`. -- Repository (DAL): `src/db/api/staff.ts` (`StaffDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/staff.ts`. -- Shared used: CRUD factories (`services/shared/crud-service.ts`, - `api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers - (`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`), - `shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `db/utils.ts` (`Utils`), - `db/api/file.ts` (`FileDBApi.replaceRelationFiles` for the `photo` relation). - -## API - -The standard generic-CRUD surface (all under `/api/staff`, JWT + `${METHOD}_STAFF` permission, -all `200`) — see `backend-architecture.md` for the shared contract: - -- `POST /` — body `{ data }`, returns `true`. -- `POST /bulk-import` — multipart CSV file, returns `true`. -- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path - param), returns `true`. -- `DELETE /:id` — returns `true`. -- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`. -- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`. -- `GET /count` — returns `{ rows: [], count }`. -- `GET /autocomplete` — `?query&limit&offset`, returns `[{ id, label }]` where `label` is - `employee_number`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `employee_number`, `job_title`, `hire_date`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('staff')`, deriving - `READ_STAFF` / `CREATE_STAFF` / `UPDATE_STAFF` / `DELETE_STAFF` per HTTP method. -- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`). - -## Tenant Scope - -- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess` - role clears the org filter (sees all tenants). -- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns - organization for `globalAccess` users (otherwise it stays the caller's org), and only when - `data.organization` is provided. - -## Data Contract - -Model columns (`paranoid`, soft-delete via `deletedAt`): - -- `id` (UUID PK), `employee_number`, `job_title` (TEXT, nullable). -- `staff_type` — ENUM `teacher` | `admin` | `support`. -- `status` — ENUM `active` | `on_leave` | `inactive`. -- `hire_date` — DATE. -- `importHash` (unique), `campusId`, `organizationId`, `userId`, `createdById`, `updatedById`, - timestamps. - -Associations: `belongsTo` organization, campus, user (a `users` record), createdBy/updatedBy -(users); `hasMany` `classes_homeroom_teacher` (classes via `homeroom_teacherId`), -`class_subjects_teacher` (class_subjects via `teacherId`), `attendance_sessions_taken_by` -(attendance_sessions via `taken_byId`); -`hasMany` file as `photo` (scoped relation). `findBy`/`GET /:id` eager-load all of these in a -single `Promise.all`. - -List filters (`StaffFilter`): `id`, `employee_number`, `job_title`, `hire_dateRange`, -`staff_type`, `status`, `campus` (id or name, `|`-separated), `user` (id or `firstName`, -`|`-separated), `organization` (id list, `|`-separated), `createdAtRange`, plus `field`/`sort` -ordering and `limit`/`page` pagination. - -## Behavior / Notes - -- `create`/`bulkImport`/`update` manage the `photo` file relation via - `FileDBApi.replaceRelationFiles`. -- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order. -- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100). -- Note: `StaffFilter` accepts an `active` flag and `findAll` filters on an `active` column, but - the model has no `active` column; this filter is currently inert (kept for source accuracy). - -## Tests - -None yet. - -## Related - -- Generic-CRUD contract: `backend-architecture.md`; related slices: `organizations`, `campuses`, - `classes`, `class_subjects`, `attendance_sessions`, `file.md`, `permissions.md`. diff --git a/backend/docs/test-coverage.md b/backend/docs/test-coverage.md index affcce7..91db6cb 100644 --- a/backend/docs/test-coverage.md +++ b/backend/docs/test-coverage.md @@ -122,7 +122,7 @@ const req = createMockRequest({ |------|-------------|-------| | `middlewares/error-handler.test.ts` | Error normalization | ~10 | | `db/api/shared/repository.test.ts` | Repository base | ~10 | -| `shared/architecture/import-boundaries.test.ts` | Architecture validation | ~5 | +| `shared/architecture/import-boundaries.test.ts` | Backend import-boundary validation: layer assignment, API/BLL/DAL/shared direction, exact exception allowlists | ~8 | ## Testing Patterns diff --git a/backend/docs/user-progress.md b/backend/docs/user-progress.md index 2a31e9f..9f9faf0 100644 --- a/backend/docs/user-progress.md +++ b/backend/docs/user-progress.md @@ -4,8 +4,9 @@ `user_progress` stores per-user progress for narrow staff workflows, keyed by a typed `progress_type` and an `item_id`. Current supported types are `sign_learned` (sign-language items -learned) and `zone_checkin` (zones-of-regulation check-ins). The backend owns tenant scope, user -ownership, validation, and persistence (one row per user + type + item, upserted). +learned), `zone_checkin` (zones-of-regulation check-ins), and `classroom_strategy_favorite` +(Classroom Support favorite strategy IDs). The backend owns tenant scope, user ownership, +validation, and persistence (one row per user + type + item, upserted). ## Slice Files (by layer) @@ -52,8 +53,9 @@ All routes require JWT authentication. Base path mounted at `/api/user_progress` ## Data Contract - Mutation input (`UserProgressInput`): `progress_type` (must be one of - `USER_PROGRESS_TYPE_VALUES`: `sign_learned`, `zone_checkin`) and `item_id` (non-empty string) are - required. Optional: `value`, `score`, `metadata`. Invalid input raises `ValidationError`. + `USER_PROGRESS_TYPE_VALUES`: `sign_learned`, `zone_checkin`, `classroom_strategy_favorite`) and + `item_id` (non-empty string) are required. Optional: `value`, `score`, `metadata`. Invalid input + raises `ValidationError`. - On save, `value` is persisted only if a string (else `null`), `score` only if a number (else `null`), and `metadata` defaults to `null` when absent; `item_id` is trimmed. - DTO fields: `id`, `progress_type`, `item_id`, `value`, `score`, `metadata`, `organizationId`, @@ -76,11 +78,13 @@ All routes require JWT authentication. Base path mounted at `/api/user_progress` ## Tests -None yet (no `user_progress` unit/e2e test in `src/`). +`src/services/user_progress.test.ts` covers own-scope persistence guards and classroom strategy +favorite upsert payload ownership. ## Related - Frontend: `frontend/docs/user-progress-integration.md`, - `frontend/docs/sign-language-integration.md`, `frontend/docs/zones-of-regulation-integration.md`. + `frontend/docs/sign-language-integration.md`, `frontend/docs/zones-of-regulation-integration.md`, + `frontend/docs/classroom-support-integration.md`. - Related slices: `safety-quiz-results.md`, `personality-quiz-results.md`, `walkthrough-checkins.md`. diff --git a/backend/docs/users.md b/backend/docs/users.md index 0f9a60c..dddd8bc 100644 --- a/backend/docs/users.md +++ b/backend/docs/users.md @@ -48,8 +48,8 @@ record when `req.params.id`/`req.body.id` equals their own id. attachment of fields `id, firstName, lastName, phoneNumber, email`. - `GET /api/users/count` -> `200` `{ rows: [], count }`. - `GET /api/users/autocomplete` -> `200` array of `{ id, label }`. -- `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions, staff - profile, custom permissions, organization). +- `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions, + custom permissions, organization). ## Access Rules @@ -79,14 +79,13 @@ Model columns (`src/db/models/users.ts`): `id` (UUID PK), `firstName`, `lastName (text), `email` (text, `allowNull: false`), `disabled` (boolean, default false), `password` (text), `emailVerified` (boolean, default false), `emailVerificationToken` + `emailVerificationTokenExpiresAt`, `passwordResetToken` + `passwordResetTokenExpiresAt`, -`provider` (text), `importHash` (unique), `organizationId`, `createdById`, `updatedById`, +`provider` (text), `organizationId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete). A `beforeCreate`/`beforeUpdate` hook trims `email`/`firstName`/`lastName`; for non-local OAuth providers `beforeCreate` forces `emailVerified = true` and generates a random bcrypt password when none is supplied. Associations: `belongsToMany permissions` as `custom_permissions` (and `custom_permissions_filter` -for list filtering) through `usersCustom_permissionsPermissions`; `hasMany staff` as `staff_user`; -`hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo +for list filtering) through `usersCustom_permissionsPermissions`; `hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` as `createdBy`/`updatedBy`. @@ -97,12 +96,12 @@ layer (`services/users.ts`) also enforces the relational role policy (`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global actors, and auto-creates the company when an `owner` is created (§3.3/§3.4). -List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `password`, -`emailVerificationToken`, `passwordResetToken`, `provider` (ILIKE); -`emailVerificationTokenExpiresAtRange`, `passwordResetTokenExpiresAtRange`, `createdAtRange`; -`active`, `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated); -`organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus `field`/`sort` -(default `createdAt desc`) and `limit`/`page`. +List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `provider` +(ILIKE); `createdAtRange`; `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated); +`campusId` (direct campus users plus class-scoped users whose class belongs to the campus); +`classId` (direct class-scoped users plus students enrolled through `class_enrollments`); +`organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus +`field`/`sort` (default `createdAt desc`) and `limit`/`page`. ## Behavior / Notes @@ -120,7 +119,7 @@ List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are emailed while bulk-imported users are not. - All mutations run inside a manual Sequelize transaction (commit on success, rollback on error). -- `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, staff profile, +- `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, custom permissions, organization) used by authentication/authorization; `findProfileById` returns the trimmed profile DTO for `GET /me`. diff --git a/backend/docs/walkthrough-checkins.md b/backend/docs/walkthrough-checkins.md index 232878a..67a88fd 100644 --- a/backend/docs/walkthrough-checkins.md +++ b/backend/docs/walkthrough-checkins.md @@ -19,11 +19,9 @@ backend owns tenant scope, campus scope, creator ownership, validation, and role - Model: `src/db/models/walkthrough_checkins.ts`. - Shared used: `services/shared/validate.ts` (`nullableString`); `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`); - `shared/constants/walkthrough.ts` (`WALKTHROUGH_MANAGER_ROLE_NAMES`, - `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, - `hasGlobalAccess`, `hasRoleAccess`, `requireUserId`); `shared/errors/*` (`ForbiddenError`, - `ValidationError`). Note: the service defines a module-local `getCampusId` and `campusScope` - (staff-profile campus only), not the shared access helpers. + `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `hasFeaturePermission`, + `hasGlobalAccess`, `requireUserId`); `shared/errors/*` (`ForbiddenError`, + `ValidationError`). ## API @@ -39,10 +37,9 @@ All routes require JWT authentication. Base path mounted at `/api/walkthrough_ch ## Access Rules -- All operations require a role in `WALKTHROUGH_MANAGER_ROLE_NAMES` (`super_admin`, - `system_admin`, `owner`, `superintendent`, `director`) or `globalAccess`, - enforced by `assertCanManage` (which also requires an authenticated user); otherwise - `ForbiddenError`. Users with `globalAccess` are always allowed. +- All operations require `MANAGE_WALKTHROUGH`. Role-seeded permissions are only + the baseline grants. `custom_permissions` can grant it and + `custom_permissions_filter` can remove it for non-global users. - Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`super_admin`, `system_admin`, `owner`, `superintendent`) or `globalAccess` see all org records; other managers (e.g. `director`) are restricted to their own staff campus on `list` and @@ -52,9 +49,7 @@ All routes require JWT authentication. Base path mounted at `/api/walkthrough_ch - Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org filter and see check-ins across all organizations; regular users are bound to their org. -- `campusId` is resolved from the module-local `getCampusId` — the current staff profile's campus - only (`currentUser.staff_user[0].campusId`), else `null`; it never falls back to the user's own - `campusId`. +- `campusId` is resolved from the current user's direct campus scope via `getCampusId`, else `null`. - On create, `createdById` is required from the current user (`requireUserId`); `updatedById` from the current user. diff --git a/backend/docs/zone-checkin.md b/backend/docs/zone-checkin.md index c9d1890..6b39159 100644 --- a/backend/docs/zone-checkin.md +++ b/backend/docs/zone-checkin.md @@ -25,20 +25,26 @@ logic, keeping the generic `user_progress` endpoint generic. ## Routes (`/api/zone_checkins`) -All require `ZONE_CHECKIN` (the four campus staff roles). +All require explicit `ZONE_CHECKIN`; `globalAccess` alone does not imply this +personal workflow permission. - `GET /today` → `{ date, zone, isCheckedInToday }` (campus-local date). - `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red). + If the caller is a parent-scope user acting through a drilled child scope, the request is accepted + as a no-op and returns today's existing personal state without saving a new row. - `DELETE /today` → clear today's check-in. - `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`). ## Authorization -- `ZONE_CHECKIN` — `director` (full access), `office_manager` (via - `...MODULE_ACTIONS`), `teacher`, `support_staff` (explicit grants). Other roles - (owner/superintendent/student/guardian/system) are not granted it; the frontend - also gates the nudge to the four campus roles (`canZoneCheckIn`). Reads/writes - are scoped to the caller's own `userId` by `UserProgressService`. +- `ZONE_CHECKIN` is seeded for `director`, `office_manager`, `teacher`, and + `support_staff`. Other users can receive or lose it only through effective + permissions (`custom_permissions` / `custom_permissions_filter`). The frontend + and backend both gate this workflow by permission, not by role name. + Reads/writes are scoped to the caller's own `userId` by `UserProgressService`. +- Check-ins persist only when the active scope is the user's own scope. Parent users drilled into a + child school/campus/classroom do not see the personal check-in UI there, and the backend does not + create reportable `user_progress` rows for that child scope. - A user with no campus has no campus-local "today" — the service rejects with a validation error (only campus staff reach these routes). diff --git a/backend/package.json b/backend/package.json index dc9b3ed..98fd290 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,8 +20,8 @@ "db:seed": "tsx src/db/umzug.ts seed:up", "db:seed:undo": "tsx src/db/umzug.ts seed:down", "db:reset": "tsx src/db/reset.ts", - "db:cleanup-tokens": "tsx src/db/cleanup-refresh-tokens.ts", - "db:cleanup-tokens:prod": "node dist/db/cleanup-refresh-tokens.js", + "db:cleanup-tokens": "tsx src/commands/cleanup-refresh-tokens.ts", + "db:cleanup-tokens:prod": "node dist/commands/cleanup-refresh-tokens.js", "watch": "tsx watcher.ts" }, "dependencies": { diff --git a/backend/src/api/controllers/auth.controller.ts b/backend/src/api/controllers/auth.controller.ts index 0a34757..60b2b21 100644 --- a/backend/src/api/controllers/auth.controller.ts +++ b/backend/src/api/controllers/auth.controller.ts @@ -82,6 +82,14 @@ export async function me(req: Request, res: Response): Promise { res.status(200).send(payload); } +export async function updateMe(req: Request, res: Response): Promise { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + const payload = await AuthService.updateOwnProfile(req.body.data, req); + res.status(200).send(payload); +} + export async function passwordReset(req: Request, res: Response): Promise { const payload = await AuthService.passwordReset( req.body.token, diff --git a/backend/src/api/controllers/class_attendance.controller.ts b/backend/src/api/controllers/class_attendance.controller.ts new file mode 100644 index 0000000..71d37b6 --- /dev/null +++ b/backend/src/api/controllers/class_attendance.controller.ts @@ -0,0 +1,17 @@ +import type { Request, Response } from 'express'; +import ClassAttendanceService from '@/services/class_attendance'; + +export async function summary(req: Request, res: Response): Promise { + const payload = await ClassAttendanceService.summary(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function upsert(req: Request, res: Response): Promise { + const payload = await ClassAttendanceService.upsert( + req.params.classId, + req.params.date, + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/communications.controller.ts b/backend/src/api/controllers/communications.controller.ts index e210816..78ccc9a 100644 --- a/backend/src/api/controllers/communications.controller.ts +++ b/backend/src/api/controllers/communications.controller.ts @@ -1,26 +1,9 @@ import type { Request, Response } from 'express'; import CommunicationsService from '@/services/communications'; -export async function listParentMessages( - req: Request, - res: Response, -): Promise { - const payload = await CommunicationsService.listParentMessages( - req.query, - req.currentUser, - ); - res.status(200).send(payload); -} - -export async function createParentMessage( - req: Request, - res: Response, -): Promise { - const payload = await CommunicationsService.createParentMessage( - req.body.data, - req.currentUser, - ); - res.status(201).send(payload); +function routeParam(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ''; + return value ?? ''; } export async function listEvents(req: Request, res: Response): Promise { @@ -38,3 +21,29 @@ export async function createEvent(req: Request, res: Response): Promise { ); res.status(201).send(payload); } + +export async function updateEvent(req: Request, res: Response): Promise { + const payload = await CommunicationsService.updateEvent( + routeParam(req.params.id), + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function deleteEvent(req: Request, res: Response): Promise { + await CommunicationsService.deleteEvent( + routeParam(req.params.id), + req.currentUser, + ); + res.status(204).send(); +} + +export async function cancelEvent(req: Request, res: Response): Promise { + const payload = await CommunicationsService.cancelEvent( + routeParam(req.params.id), + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} diff --git a/backend/src/api/controllers/content_catalog.controller.ts b/backend/src/api/controllers/content_catalog.controller.ts index f87826c..e047151 100644 --- a/backend/src/api/controllers/content_catalog.controller.ts +++ b/backend/src/api/controllers/content_catalog.controller.ts @@ -14,6 +14,15 @@ export async function create(req: Request, res: Response): Promise { res.status(201).send(payload); } +/** Authenticated read for any tenant user; returns content scoped to the user. */ +export async function readByType(req: Request, res: Response): Promise { + const payload = await ContentCatalogService.findByType( + req.params.contentType, + req.currentUser, + ); + res.status(200).send(payload); +} + export async function findManagedByType( req: Request, res: Response, diff --git a/backend/src/api/controllers/direct_messages.controller.ts b/backend/src/api/controllers/direct_messages.controller.ts new file mode 100644 index 0000000..52d1271 --- /dev/null +++ b/backend/src/api/controllers/direct_messages.controller.ts @@ -0,0 +1,26 @@ +import type { Request, Response } from 'express'; +import DirectMessagesService from '@/services/direct_messages'; + +export async function contacts(req: Request, res: Response): Promise { + const payload = await DirectMessagesService.contacts(req.currentUser); + res.status(200).send(payload); +} + +export async function conversations(req: Request, res: Response): Promise { + const payload = await DirectMessagesService.conversations(req.currentUser); + res.status(200).send(payload); +} + +export async function thread(req: Request, res: Response): Promise { + const payload = await DirectMessagesService.thread( + req.params.otherUserId, + req.query.studentId, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function send(req: Request, res: Response): Promise { + const payload = await DirectMessagesService.send(req.body.data, req.currentUser); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/guardian_students.controller.ts b/backend/src/api/controllers/guardian_students.controller.ts new file mode 100644 index 0000000..7e05f86 --- /dev/null +++ b/backend/src/api/controllers/guardian_students.controller.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from 'express'; +import GuardianStudentsService from '@/services/guardian_students'; + +export async function list(req: Request, res: Response): Promise { + const payload = await GuardianStudentsService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function link(req: Request, res: Response): Promise { + const payload = await GuardianStudentsService.link( + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function unlink(req: Request, res: Response): Promise { + const id = String(req.params.id); + await GuardianStudentsService.unlink(id, req.currentUser); + res.status(200).send({ id }); +} diff --git a/backend/src/api/controllers/iam_capabilities.controller.ts b/backend/src/api/controllers/iam_capabilities.controller.ts new file mode 100644 index 0000000..70617da --- /dev/null +++ b/backend/src/api/controllers/iam_capabilities.controller.ts @@ -0,0 +1,6 @@ +import type { Request, Response } from 'express'; +import IamCapabilitiesService from '@/services/iam_capabilities'; + +export function current(req: Request, res: Response): void { + res.status(200).send(IamCapabilitiesService.current(req.currentUser)); +} diff --git a/backend/src/api/controllers/platform.controller.ts b/backend/src/api/controllers/platform.controller.ts new file mode 100644 index 0000000..58078a0 --- /dev/null +++ b/backend/src/api/controllers/platform.controller.ts @@ -0,0 +1,7 @@ +import type { Request, Response } from 'express'; +import PlatformService from '@/services/platform'; + +export async function stats(req: Request, res: Response): Promise { + const payload = await PlatformService.stats(req.currentUser); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/policy_acknowledgments.controller.ts b/backend/src/api/controllers/policy_acknowledgments.controller.ts index 3f54443..6c0b755 100644 --- a/backend/src/api/controllers/policy_acknowledgments.controller.ts +++ b/backend/src/api/controllers/policy_acknowledgments.controller.ts @@ -9,6 +9,11 @@ export async function list(req: Request, res: Response): Promise { res.status(200).send(payload); } +export async function report(req: Request, res: Response): Promise { + const payload = await PolicyAcknowledgmentsService.report(req.currentUser); + res.status(200).send(payload); +} + export async function acknowledge(req: Request, res: Response): Promise { const payload = await PolicyAcknowledgmentsService.acknowledge( req.body.data, diff --git a/backend/src/api/controllers/public_content_catalog.controller.ts b/backend/src/api/controllers/public_content_catalog.controller.ts deleted file mode 100644 index 3ec78f3..0000000 --- a/backend/src/api/controllers/public_content_catalog.controller.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Request, Response } from 'express'; -import { paramStr } from '@/api/http/request'; -import ContentCatalogService from '@/services/content_catalog'; - -export async function findByType(req: Request, res: Response): Promise { - const payload = await ContentCatalogService.findByType( - paramStr(req.params.contentType), - ); - res.status(200).send(payload); -} diff --git a/backend/src/api/controllers/schools.controller.ts b/backend/src/api/controllers/schools.controller.ts new file mode 100644 index 0000000..4f917ea --- /dev/null +++ b/backend/src/api/controllers/schools.controller.ts @@ -0,0 +1,20 @@ +import type { Request, Response } from 'express'; +import service from '@/services/schools'; +import { createCrudController } from '@/api/controllers/shared/crud-controller'; + +/** Custom: atomically create a school + its first campus. */ +export async function createWithFirstCampus( + req: Request, + res: Response, +): Promise { + const payload = await service.createWithFirstCampus( + req.body.data, + req.body.firstCampus, + req.currentUser, + ); + res.status(200).send(payload); +} + +export default createCrudController(service, { + csvFields: ['id', 'name', 'code', 'address', 'phone', 'email'], +}); diff --git a/backend/src/api/controllers/scope.controller.ts b/backend/src/api/controllers/scope.controller.ts new file mode 100644 index 0000000..54f40f6 --- /dev/null +++ b/backend/src/api/controllers/scope.controller.ts @@ -0,0 +1,13 @@ +import type { Request, Response } from 'express'; +import { queryNum } from '@/api/http/request'; +import ScopeService from '@/services/scope'; + +export async function children(req: Request, res: Response): Promise { + const payload = await ScopeService.listChildren( + req.query.parentLevel, + req.query.parentId, + queryNum(req.query.limit), + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/staff.controller.ts b/backend/src/api/controllers/staff.controller.ts deleted file mode 100644 index 16ebd0e..0000000 --- a/backend/src/api/controllers/staff.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/staff'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'employee_number', 'job_title', 'hire_date'] }); diff --git a/backend/src/api/controllers/staff_attendance.controller.ts b/backend/src/api/controllers/staff_attendance.controller.ts index cddaa4f..34b1a7a 100644 --- a/backend/src/api/controllers/staff_attendance.controller.ts +++ b/backend/src/api/controllers/staff_attendance.controller.ts @@ -16,3 +16,15 @@ export async function summary(req: Request, res: Response): Promise { ); res.status(200).send(payload); } + +export async function upsertRecord(req: Request, res: Response): Promise { + const payload = await StaffAttendanceService.upsertRecord( + { + ...req.body.data, + userId: req.params.userId, + date: req.params.date, + }, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/users.controller.ts b/backend/src/api/controllers/users.controller.ts index f0c0f0f..7ead168 100644 --- a/backend/src/api/controllers/users.controller.ts +++ b/backend/src/api/controllers/users.controller.ts @@ -20,8 +20,26 @@ function hostFromReferer(req: Request): string { } export async function create(req: Request, res: Response): Promise { - await Service.create(req.body.data, req.currentUser, true, hostFromReferer(req)); - res.status(200).send(true); + const result = await Service.create( + req.body.data, + req.currentUser, + true, + hostFromReferer(req), + ); + res.status(200).send(result ?? true); +} + +export async function createOwnerWithOrganization( + req: Request, + res: Response, +): Promise { + const payload = await Service.createOwnerWithOrganization( + req.body.data, + req.currentUser, + true, + hostFromReferer(req), + ); + res.status(200).send(payload); } export async function bulkImport(req: Request, res: Response): Promise { diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 7b43af1..d3872c4 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -3,7 +3,6 @@ import { Strategy as JwtStrategy } from 'passport-jwt'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import type { Request } from 'express'; import config from '@/shared/config'; -import db from '@/db/models'; import UsersDBApi from '@/db/api/users'; import cookies from '@/auth/cookies'; @@ -50,9 +49,8 @@ function socialStrategy( provider: string, done: SocialDone, ): void { - db.users - .findOrCreate({ where: { email, provider } }) - .then(([user]) => done(null, { user })) + UsersDBApi.findOrCreateSocialIdentity(email, provider) + .then((user) => done(null, { user })) .catch((error: unknown) => done(error)); } diff --git a/backend/src/db/cleanup-refresh-tokens.ts b/backend/src/commands/cleanup-refresh-tokens.ts similarity index 60% rename from backend/src/db/cleanup-refresh-tokens.ts rename to backend/src/commands/cleanup-refresh-tokens.ts index c799a69..a9a1fca 100644 --- a/backend/src/db/cleanup-refresh-tokens.ts +++ b/backend/src/commands/cleanup-refresh-tokens.ts @@ -1,23 +1,25 @@ -import db from '@/db/models'; -import { cleanupExpiredRefreshTokens } from '@/services/refresh-token-maintenance'; +import { + cleanupExpiredRefreshTokens, + closeRefreshTokenMaintenanceConnection, +} from '@/services/refresh-token-maintenance'; /** * Operational maintenance command: delete refresh-token rows that expired before * the retention window (`AUTH_REFRESH_TOKEN_RETENTION_MS`, default 7 days). Run * on a schedule (cron / platform scheduler): * - * npm run db:cleanup-tokens # dev (tsx) - * node dist/db/cleanup-refresh-tokens.js # prod (built) + * npm run db:cleanup-tokens # dev (tsx) + * node dist/commands/cleanup-refresh-tokens.js # prod (built) */ async function run(): Promise { const { deleted, cutoff } = await cleanupExpiredRefreshTokens(); console.log( `Refresh-token cleanup complete: ${deleted} row(s) removed (cutoff ${cutoff.toISOString()}).`, ); - await db.sequelize.close(); + await closeRefreshTokenMaintenanceConnection(); } -run().catch((error) => { +run().catch((error: unknown) => { console.error('Refresh-token cleanup failed:', error); process.exit(1); }); diff --git a/backend/src/db/api/attendance_sessions.ts b/backend/src/db/api/attendance_sessions.ts index 3444c61..b5dbabd 100644 --- a/backend/src/db/api/attendance_sessions.ts +++ b/backend/src/db/api/attendance_sessions.ts @@ -317,7 +317,7 @@ class Attendance_sessionsDBApi { : {}, }, { - model: db.staff, + model: db.users, as: 'taken_by', where: filter.taken_by ? { @@ -330,7 +330,7 @@ class Attendance_sessionsDBApi { }, }, { - employee_number: { + email: { [Op.or]: filter.taken_by .split('|') .map((t) => ({ [Op.iLike]: `%${t}%` })), diff --git a/backend/src/db/api/campuses.ts b/backend/src/db/api/campuses.ts index 1d683b6..986a688 100644 --- a/backend/src/db/api/campuses.ts +++ b/backend/src/db/api/campuses.ts @@ -21,6 +21,8 @@ import type { CurrentUser, DbApiOptions } from '@/db/api/types'; type CampusesData = Partial> & { organization?: string | null; + /** Owning school (Organization → School → Campus). Optional. */ + schoolId?: string | null; }; interface CampusesFilter { @@ -69,6 +71,7 @@ class CampusesDBApi { textColor: data.textColor || null, bgLight: data.bgLight || null, description: data.description || null, + logo: data.logo || null, isOnline: data.isOnline || false, active: data.active || false, importHash: data.importHash || null, @@ -78,9 +81,21 @@ class CampusesDBApi { { transaction }, ); - await campuses.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); + // Org resolution: own org (non-global) → explicit org (global) → inherited + // from the chosen school. Keeps tenant isolation while letting global + // creators place a campus correctly. + let organizationId = currentUser.organizationId ?? data.organization ?? null; + if (!organizationId && data.schoolId) { + const school = await db.schools.findByPk(data.schoolId, { + attributes: ['organizationId'], + transaction, + }); + organizationId = school?.organizationId ?? null; + } + await campuses.setOrganization(organizationId ?? undefined, { transaction }); + if (data.schoolId) { + await campuses.setSchool(data.schoolId, { transaction }); + } return campuses; } @@ -155,6 +170,7 @@ class CampusesDBApi { if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight; if (data.description !== undefined) updatePayload.description = data.description; + if (data.logo !== undefined) updatePayload.logo = data.logo; if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline; if (data.active !== undefined) updatePayload.active = data.active; @@ -204,21 +220,18 @@ class CampusesDBApi { const output: Record = campuses.get({ plain: true }); const [ - staff_campus, classes_campus, timetables_campus, attendance_sessions_campus, messages_campus, organization, ] = await Promise.all([ - campuses.getStaff_campus({ transaction }), campuses.getClasses_campus({ transaction }), campuses.getTimetables_campus({ transaction }), campuses.getAttendance_sessions_campus({ transaction }), campuses.getMessages_campus({ transaction }), campuses.getOrganization({ transaction }), ]); - output.staff_campus = staff_campus; output.classes_campus = classes_campus; output.timetables_campus = timetables_campus; output.attendance_sessions_campus = attendance_sessions_campus; diff --git a/backend/src/db/api/class_subjects.ts b/backend/src/db/api/class_subjects.ts index d6c5879..2ba0e95 100644 --- a/backend/src/db/api/class_subjects.ts +++ b/backend/src/db/api/class_subjects.ts @@ -259,7 +259,7 @@ class Class_subjectsDBApi { : {}, }, { - model: db.staff, + model: db.users, as: 'teacher', where: filter.teacher ? { @@ -270,7 +270,7 @@ class Class_subjectsDBApi { }, }, { - employee_number: { + email: { [Op.or]: filter.teacher .split('|') .map((t) => ({ [Op.iLike]: `%${t}%` })), diff --git a/backend/src/db/api/classes.ts b/backend/src/db/api/classes.ts index ddef857..bbddc65 100644 --- a/backend/src/db/api/classes.ts +++ b/backend/src/db/api/classes.ts @@ -63,6 +63,7 @@ class ClassesDBApi { section: data.section || null, capacity: data.capacity || null, status: data.status || null, + logo: data.logo || null, importHash: data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -70,9 +71,17 @@ class ClassesDBApi { { transaction }, ); - await classes.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); + // Org resolution: own org (non-global) → explicit org (global) → inherited + // from the chosen campus. + let organizationId = currentUser.organizationId ?? data.organization ?? null; + if (!organizationId && data.campus) { + const campus = await db.campuses.findByPk(data.campus, { + attributes: ['organizationId'], + transaction, + }); + organizationId = campus?.organizationId ?? null; + } + await classes.setOrganization(organizationId ?? undefined, { transaction }); await classes.setCampus(data.campus ?? undefined, { transaction }); await classes.setAcademic_year(data.academic_year ?? undefined, { transaction, @@ -128,6 +137,7 @@ class ClassesDBApi { if (data.section !== undefined) updatePayload.section = data.section; if (data.capacity !== undefined) updatePayload.capacity = data.capacity; if (data.status !== undefined) updatePayload.status = data.status; + if (data.logo !== undefined) updatePayload.logo = data.logo; updatePayload.updatedById = currentUser.id; @@ -306,7 +316,7 @@ class ClassesDBApi { : {}, }, { - model: db.staff, + model: db.users, as: 'homeroom_teacher', where: filter.homeroom_teacher ? { @@ -319,7 +329,7 @@ class ClassesDBApi { }, }, { - employee_number: { + email: { [Op.or]: filter.homeroom_teacher .split('|') .map((t) => ({ [Op.iLike]: `%${t}%` })), diff --git a/backend/src/db/api/organizations.ts b/backend/src/db/api/organizations.ts index ce8d3b4..ffa22fb 100644 --- a/backend/src/db/api/organizations.ts +++ b/backend/src/db/api/organizations.ts @@ -43,6 +43,7 @@ class OrganizationsDBApi { { id: data.id || undefined, name: data.name || null, + logo: data.logo || null, importHash: data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -87,6 +88,7 @@ class OrganizationsDBApi { const updatePayload: Partial> = {}; if (data.name !== undefined) updatePayload.name = data.name; + if (data.logo !== undefined) updatePayload.logo = data.logo; updatePayload.updatedById = currentUser.id; @@ -129,7 +131,6 @@ class OrganizationsDBApi { academic_years_organization, grades_organization, subjects_organization, - staff_organization, classes_organization, class_enrollments_organization, class_subjects_organization, @@ -147,7 +148,6 @@ class OrganizationsDBApi { organizations.getAcademic_years_organization({ transaction }), organizations.getGrades_organization({ transaction }), organizations.getSubjects_organization({ transaction }), - organizations.getStaff_organization({ transaction }), organizations.getClasses_organization({ transaction }), organizations.getClass_enrollments_organization({ transaction }), organizations.getClass_subjects_organization({ transaction }), @@ -165,7 +165,6 @@ class OrganizationsDBApi { output.academic_years_organization = academic_years_organization; output.grades_organization = grades_organization; output.subjects_organization = subjects_organization; - output.staff_organization = staff_organization; output.classes_organization = classes_organization; output.class_enrollments_organization = class_enrollments_organization; output.class_subjects_organization = class_subjects_organization; diff --git a/backend/src/db/api/policy_documents.ts b/backend/src/db/api/policy_documents.ts index 3f5f2af..7229132 100644 --- a/backend/src/db/api/policy_documents.ts +++ b/backend/src/db/api/policy_documents.ts @@ -10,8 +10,12 @@ import { deleteRecordsByIds, autocompleteByField, findOwnedByPk, - tenantWhere, } from '@/db/api/shared/repository'; +import { + getOwnTenant, + tenantExactWhere, + tenantStamp, +} from '@/shared/tenancy'; import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; import { resolvePagination } from '@/shared/constants/pagination'; import { @@ -70,6 +74,8 @@ class Policy_documentsDBApi { ): Promise { const currentUser = options?.currentUser ?? NO_USER; const transaction = options?.transaction; + // Per-tenant content: a new document is owned by the author's own tenant level. + const stamp = tenantStamp(getOwnTenant(currentUser)); const record = await db.policy_documents.create( { @@ -85,8 +91,10 @@ class Policy_documentsDBApi { version: data.version ?? 1, active: data.active ?? true, importHash: data.importHash || null, - organizationId: currentUser.organizationId ?? null, - campusId: data.campus ?? currentUser.campusId ?? null, + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: stamp.classId, createdById: currentUser.id, updatedById: currentUser.id, }, @@ -102,6 +110,7 @@ class Policy_documentsDBApi { ): Promise { const currentUser = options?.currentUser ?? NO_USER; const transaction = options?.transaction; + const stamp = tenantStamp(getOwnTenant(currentUser)); const rows = data.map((item, index) => ({ id: item.id || undefined, @@ -115,8 +124,10 @@ class Policy_documentsDBApi { version: item.version ?? 1, active: item.active ?? true, importHash: item.importHash || null, - organizationId: currentUser.organizationId ?? null, - campusId: item.campus ?? currentUser.campusId ?? null, + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: stamp.classId, createdById: currentUser.id, updatedById: currentUser.id, createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), @@ -189,7 +200,7 @@ class Policy_documentsDBApi { const transaction = options?.transaction; const record = await db.policy_documents.findOne({ - where: { ...where, ...tenantWhere(options?.currentUser) }, + where: { ...where, ...tenantExactWhere(getOwnTenant(options?.currentUser)) }, transaction, }); if (!record) { @@ -208,17 +219,15 @@ class Policy_documentsDBApi { static async findAll( filter: PolicyDocumentsFilter, - globalAccess: boolean, + _globalAccess: boolean, options?: DbApiOptions, ): Promise<{ rows: PolicyDocuments[]; count: number }> { const { limit, offset } = resolvePagination(filter.limit, filter.page); - let where: WhereAttributeHash = {}; - - const userOrganizations = options?.currentUser?.organizations?.id ?? null; - if (userOrganizations && options?.currentUser?.organizationId) { - where.organizationId = options.currentUser.organizationId; - } + // Per-tenant content: documents dedicated to the user's own tenant level. + let where: WhereAttributeHash = { + ...tenantExactWhere(getOwnTenant(options?.currentUser)), + }; if (filter.id) { where = { ...where, id: Utils.uuid(filter.id) }; @@ -241,19 +250,6 @@ class Policy_documentsDBApi { active: filter.active === true || filter.active === 'true', }; } - if (filter.campus) { - where = { ...where, campusId: Utils.uuid(filter.campus) }; - } - if (filter.organization) { - const listItems = filter.organization - .split('|') - .map((item) => Utils.uuid(item)); - where = { ...where, organizationId: { [Op.or]: listItems } }; - } - - if (globalAccess) { - delete where.organizationId; - } const order: [string, string][] = filter.field && filter.sort diff --git a/backend/src/db/api/schools.ts b/backend/src/db/api/schools.ts new file mode 100644 index 0000000..285c923 --- /dev/null +++ b/backend/src/db/api/schools.ts @@ -0,0 +1,269 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, + findOwnedByPk, + tenantWhere, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import ValidationError from '@/shared/errors/validation'; +import type { Schools } from '@/db/models/schools'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type SchoolsData = Partial> & { + organization?: string | null; +}; + +interface SchoolsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + code?: string; + address?: string; + phone?: string; + email?: string; + active?: boolean | string; + organization?: string; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class SchoolsDBApi { + static async create( + data: SchoolsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + if (data.name == null || data.code == null) { + throw new ValidationError(); + } + + const school = await db.schools.create( + { + id: data.id || undefined, + name: data.name, + code: data.code, + address: data.address || null, + phone: data.phone || null, + email: data.email || null, + description: data.description || null, + logo: data.logo || null, + active: data.active || false, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + // Non-global creators are pinned to their own org (tenant isolation); + // global creators (no own org) may target an org explicitly via `data`. + await school.setOrganization( + currentUser.organizationId ?? data.organization ?? undefined, + { transaction }, + ); + + return school; + } + + static async bulkImport( + data: SchoolsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const schoolsData = data.map((item, index) => { + if (item.name == null || item.code == null) { + throw new ValidationError(); + } + return { + id: item.id || undefined, + name: item.name, + code: item.code, + address: item.address || null, + phone: item.phone || null, + email: item.email || null, + description: item.description || null, + logo: item.logo || null, + active: item.active || false, + importHash: item.importHash || null, + organizationId: currentUser.organizationId ?? null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + }; + }); + + return db.schools.bulkCreate(schoolsData, { transaction }); + } + + static async update( + id: string, + data: SchoolsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const school = await findOwnedByPk(db.schools, id, options); + + if (!school) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.code !== undefined) updatePayload.code = data.code; + if (data.address !== undefined) updatePayload.address = data.address; + if (data.phone !== undefined) updatePayload.phone = data.phone; + if (data.email !== undefined) updatePayload.email = data.email; + if (data.description !== undefined) + updatePayload.description = data.description; + if (data.logo !== undefined) updatePayload.logo = data.logo; + if (data.active !== undefined) updatePayload.active = data.active; + + updatePayload.updatedById = currentUser.id; + + await school.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await school.setOrganization(orgId ?? undefined, { transaction }); + } + + return school; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.schools, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.schools, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const school = await db.schools.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + include: [ + { model: db.organizations, as: 'organization' }, + { model: db.campuses, as: 'campuses_school' }, + ], + transaction, + }); + + if (!school) { + return null; + } + + return school.get({ plain: true }); + } + + static async findAll( + filter: SchoolsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Schools[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { ...where, [Op.and]: Utils.ilike('schools', 'name', filter.name) }; + } + if (filter.code) { + where = { ...where, [Op.and]: Utils.ilike('schools', 'code', filter.code) }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.schools.findAndCountAll({ + where, + include: [{ model: db.organizations, as: 'organization' }], + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.schools, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default SchoolsDBApi; diff --git a/backend/src/db/api/staff.ts b/backend/src/db/api/staff.ts deleted file mode 100644 index 861ba96..0000000 --- a/backend/src/db/api/staff.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, - findOwnedByPk, - tenantWhere, -} from '@/db/api/shared/repository'; -import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; -import { resolvePagination } from '@/shared/constants/pagination'; -import Utils from '@/db/utils'; -import FileDBApi from '@/db/api/file'; -import type { Staff } from '@/db/models/staff'; -import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; - -type StaffData = Partial> & { - organization?: string | null; - campus?: string | null; - user?: string | null; - photo?: FileInput | FileInput[] | null; -}; - -interface StaffFilter { - limit?: number | string; - page?: number | string; - id?: string; - employee_number?: string; - job_title?: string; - hire_dateRange?: Array; - active?: boolean | string; - staff_type?: string; - status?: string; - campus?: string; - user?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -function staffTableName(): string { - const name = db.staff.getTableName(); - return typeof name === 'string' ? name : name.tableName; -} - -class StaffDBApi { - static async create( - data: StaffData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const staff = await db.staff.create( - { - id: data.id || undefined, - employee_number: data.employee_number || null, - job_title: data.job_title || null, - staff_type: data.staff_type || null, - hire_date: data.hire_date || null, - status: data.status || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await staff.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await staff.setCampus(data.campus ?? undefined, { transaction }); - await staff.setUser(data.user ?? undefined, { transaction }); - - await FileDBApi.replaceRelationFiles( - { - belongsTo: staffTableName(), - belongsToColumn: 'photo', - belongsToId: staff.id, - }, - data.photo, - options, - ); - - return staff; - } - - static async bulkImport( - data: StaffData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const staffData = data.map((item, index) => ({ - id: item.id || undefined, - employee_number: item.employee_number || null, - job_title: item.job_title || null, - staff_type: item.staff_type || null, - hire_date: item.hire_date || null, - status: item.status || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - const staff = await db.staff.bulkCreate(staffData, { transaction }); - - for (let i = 0; i < staff.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: staffTableName(), - belongsToColumn: 'photo', - belongsToId: staff[i].id, - }, - data[i].photo, - options, - ); - } - - return staff; - } - - static async update( - id: string, - data: StaffData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const staff = await findOwnedByPk(db.staff, id, options); - - if (!staff) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.employee_number !== undefined) - updatePayload.employee_number = data.employee_number; - if (data.job_title !== undefined) updatePayload.job_title = data.job_title; - if (data.staff_type !== undefined) - updatePayload.staff_type = data.staff_type; - if (data.hire_date !== undefined) updatePayload.hire_date = data.hire_date; - if (data.status !== undefined) updatePayload.status = data.status; - - updatePayload.updatedById = currentUser.id; - - await staff.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await staff.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.campus !== undefined) { - await staff.setCampus(data.campus ?? undefined, { transaction }); - } - if (data.user !== undefined) { - await staff.setUser(data.user ?? undefined, { transaction }); - } - - await FileDBApi.replaceRelationFiles( - { - belongsTo: staffTableName(), - belongsToColumn: 'photo', - belongsToId: staff.id, - }, - data.photo, - options, - ); - - return staff; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.staff, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.staff, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const staff = await db.staff.findOne({ - where: { ...where, ...tenantWhere(options?.currentUser) }, - transaction, - }); - - if (!staff) { - return null; - } - - const output: Record = staff.get({ plain: true }); - - const [ - classes_homeroom_teacher, - class_subjects_teacher, - attendance_sessions_taken_by, - organization, - campus, - user, - photo, - ] = await Promise.all([ - staff.getClasses_homeroom_teacher({ transaction }), - staff.getClass_subjects_teacher({ transaction }), - staff.getAttendance_sessions_taken_by({ transaction }), - staff.getOrganization({ transaction }), - staff.getCampus({ transaction }), - staff.getUser({ transaction }), - staff.getPhoto({ transaction }), - ]); - output.classes_homeroom_teacher = classes_homeroom_teacher; - output.class_subjects_teacher = class_subjects_teacher; - output.attendance_sessions_taken_by = attendance_sessions_taken_by; - output.organization = organization; - output.campus = campus; - output.user = user; - output.photo = photo; - - return output; - } - - static async findAll( - filter: StaffFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Staff[]; count: number }> { - const { limit, offset } = resolvePagination(filter.limit, filter.page); - - let where: WhereAttributeHash = {}; - - const userOrganizations = options?.currentUser?.organizations?.id ?? null; - if (userOrganizations && options?.currentUser?.organizationId) { - where.organizationId = options.currentUser.organizationId; - } - - const include: Includeable[] = [ - { model: db.organizations, as: 'organization' }, - { - model: db.campuses, - as: 'campus', - where: filter.campus - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - name: { - [Op.or]: filter.campus - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { - model: db.users, - as: 'user', - where: filter.user - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.user.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - firstName: { - [Op.or]: filter.user - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { model: db.file, as: 'photo' }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.employee_number) { - where = { - ...where, - [Op.and]: Utils.ilike('staff', 'employee_number', filter.employee_number), - }; - } - if (filter.job_title) { - where = { - ...where, - [Op.and]: Utils.ilike('staff', 'job_title', filter.job_title), - }; - } - if (filter.hire_dateRange) { - const [start, end] = filter.hire_dateRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, hire_date: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - hire_date: { - ...(typeof where.hire_date === 'object' ? where.hire_date : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.staff_type) { - where = { ...where, staff_type: filter.staff_type }; - } - if (filter.status) { - where = { ...where, status: filter.status }; - } - if (filter.organization) { - const listItems = filter.organization - .split('|') - .map((item) => Utils.uuid(item)); - where = { ...where, organizationId: { [Op.or]: listItems } }; - } - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, createdAt: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - createdAt: { - ...(typeof where.createdAt === 'object' ? where.createdAt : {}), - [Op.lte]: end, - }, - }; - } - } - - if (globalAccess) { - delete where.organizationId; - } - - const order: [string, string][] = - filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']]; - - const { rows, count } = await db.staff.findAndCountAll({ - where, - include, - distinct: true, - order, - transaction: options?.transaction, - limit: !options?.countOnly && limit ? limit : undefined, - offset: !options?.countOnly && offset ? offset : undefined, - }); - - return { rows: options?.countOnly ? [] : rows, count }; - } - - static async findAllAutocomplete( - query: string | undefined, - limit: number | undefined, - offset: number | undefined, - globalAccess: boolean, - organizationId: string | undefined, - ): Promise> { - return autocompleteByField( - db.staff, - 'employee_number', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default StaffDBApi; diff --git a/backend/src/db/api/types.ts b/backend/src/db/api/types.ts index e8e986a..39f51ff 100644 --- a/backend/src/db/api/types.ts +++ b/backend/src/db/api/types.ts @@ -1,10 +1,12 @@ import type { InferAttributes, Transaction } from 'sequelize'; import type { Users } from '@/db/models/users'; -import type { Staff } from '@/db/models/staff'; import type { Roles } from '@/db/models/roles'; import type { Permissions } from '@/db/models/permissions'; import type { Organizations } from '@/db/models/organizations'; import type { Campuses } from '@/db/models/campuses'; +import type { Schools } from '@/db/models/schools'; +import type { Classes } from '@/db/models/classes'; +import type { File } from '@/db/models/file'; /** A permission record, reduced to the fields consumers read. */ export interface PermissionLike { @@ -23,13 +25,20 @@ export interface UserProfileRecord { name_prefix: string | null; firstName: string | null; lastName: string | null; + phoneNumber: string | null; organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; organizations: Organizations | null; + school: Schools | null; + campus: Campuses | null; + class: Classes | null; app_role: Roles | null; app_role_permissions: Permissions[]; custom_permissions: Permissions[]; - staff_user: Staff[]; - staff_campus: Campuses | null; + custom_permissions_filter: Permissions[]; + avatar: File[]; } /** @@ -38,11 +47,13 @@ export interface UserProfileRecord { * {@link CurrentUser}, so it is assignable to `req.currentUser` without a cast. */ export type AuthenticatedUser = InferAttributes & { - staff_user: Staff[]; app_role: Roles | null; app_role_permissions: Permissions[]; custom_permissions: Permissions[]; + custom_permissions_filter: Permissions[]; organizations: Organizations | null; + /** Drill-down override resolved by the active-scope middleware. */ + activeScope?: CurrentUser['activeScope']; }; /** Minimal shape of the authenticated user passed through the data layer. */ @@ -57,6 +68,8 @@ export interface CurrentUser { scope?: string | null; /** Present on the loaded role instance attached to the request. */ getPermissions?: () => Promise; + /** Present when role permissions are eager-loaded with the request user. */ + permissions?: PermissionLike[] | null; } | null; /** * Present when the value is the full authenticated user record attached to @@ -65,20 +78,30 @@ export interface CurrentUser { */ password?: string | null; custom_permissions?: PermissionLike[] | null; + custom_permissions_filter?: PermissionLike[] | null; name_prefix?: string | null; firstName?: string | null; lastName?: string | null; + phoneNumber?: string | null; email?: string | null; campusId?: string | null; campus?: { code?: string | null; name?: string | null } | null; schoolId?: string | null; school?: { id?: string | null; name?: string | null } | null; - staff_user?: Array<{ - campusId?: string | null; - schoolId?: string | null; - staff_type?: string | null; - campus?: { code?: string | null; name?: string | null } | null; - }> | null; + classId?: string | null; + class?: { id?: string | null; name?: string | null } | null; + /** + * Drill-down override: when set (resolved + validated from the active-tenant + * request header), scope resolution acts as this tenant instead of the user's + * own. The full chain is resolved so leaf-getters return the right ids. + */ + activeScope?: { + level: 'organization' | 'school' | 'campus' | 'class'; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; + } | null; } /** Common options accepted by db/api methods. */ diff --git a/backend/src/db/api/users-search.test.ts b/backend/src/db/api/users-search.test.ts new file mode 100644 index 0000000..51d9790 --- /dev/null +++ b/backend/src/db/api/users-search.test.ts @@ -0,0 +1,157 @@ +import { afterEach, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { inspect } from 'node:util'; +import { Op, type FindAndCountOptions } from 'sequelize'; + +import db from '@/db/models'; +import UsersDBApi from '@/db/api/users'; + +afterEach(() => { + mock.restoreAll(); +}); + +test('UsersDBApi query search uses correlated subqueries for related names', async () => { + let capturedOptions: FindAndCountOptions | null = null; + + mock.method( + db.users, + 'findAndCountAll', + (async (options: FindAndCountOptions) => { + capturedOptions = options; + return { rows: [], count: 0 }; + }) as unknown as typeof db.users.findAndCountAll, + ); + + await UsersDBApi.findAll({ query: 'John', limit: 10, page: 0 }, true, {}); + + assert.ok(capturedOptions, 'expected findAndCountAll to be called'); + const options = capturedOptions as FindAndCountOptions; + const where = options.where as Record; + const andConditions = where[Op.and]; + + assert.ok(Array.isArray(andConditions), 'expected query search conditions in Op.and'); + const serializedWhere = inspect(where, { depth: 10 }); + + assert.ok( + serializedWhere.includes('FROM "organizations"'), + 'expected organization name search to use a correlated subquery', + ); + assert.ok( + serializedWhere.includes('FROM "schools"'), + 'expected school name search to use a correlated subquery', + ); + assert.ok( + serializedWhere.includes('FROM "campuses"'), + 'expected campus name search to use a correlated subquery', + ); + assert.ok( + serializedWhere.includes('FROM "classes"'), + 'expected class name search to use a correlated subquery', + ); + assert.ok( + serializedWhere.includes('FROM "roles"'), + 'expected role name search to use a correlated subquery', + ); + assert.equal( + serializedWhere.includes('lower("school"."name")') + || serializedWhere.includes('lower("campus"."name")') + || serializedWhere.includes('lower("class"."name")') + || serializedWhere.includes('lower("app_role"."name")'), + false, + 'expected no direct joined-alias name references in the where clause', + ); +}); + +test('UsersDBApi ignores removed user filters', async () => { + let capturedOptions: FindAndCountOptions | null = null; + + mock.method( + db.users, + 'findAndCountAll', + (async (options: FindAndCountOptions) => { + capturedOptions = options; + return { rows: [], count: 0 }; + }) as unknown as typeof db.users.findAndCountAll, + ); + + const removedFilter = { + active: 'true', + password: 'secret', + emailVerificationToken: 'verify-token', + passwordResetToken: 'reset-token', + disabled: 'false', + limit: 10, + page: 0, + }; + + await UsersDBApi.findAll(removedFilter, true, {}); + + assert.ok(capturedOptions, 'expected findAndCountAll to be called'); + const options = capturedOptions as FindAndCountOptions; + const where = options.where as Record; + const serializedWhere = inspect(where, { depth: 10 }); + + assert.equal(where.disabled, false); + assert.equal(serializedWhere.includes('active'), false); + assert.equal(serializedWhere.includes('password'), false); + assert.equal(serializedWhere.includes('emailVerificationToken'), false); + assert.equal(serializedWhere.includes('passwordResetToken'), false); +}); + +test('UsersDBApi campus filter includes class-scoped users inside that campus', async () => { + let capturedOptions: FindAndCountOptions | null = null; + + mock.method( + db.users, + 'findAndCountAll', + (async (options: FindAndCountOptions) => { + capturedOptions = options; + return { rows: [], count: 0 }; + }) as unknown as typeof db.users.findAndCountAll, + ); + + await UsersDBApi.findAll( + { campusId: '00000000-0000-4000-8000-000000000001', limit: 10, page: 0 }, + true, + {}, + ); + + assert.ok(capturedOptions, 'expected findAndCountAll to be called'); + const options = capturedOptions as FindAndCountOptions; + const where = options.where as Record; + const serializedWhere = inspect(where, { depth: 10 }); + + assert.ok( + serializedWhere.includes('FROM "classes" WHERE "campusId"'), + 'expected campus filtering to include class-scoped users', + ); +}); + +test('UsersDBApi class filter includes enrolled students', async () => { + let capturedOptions: FindAndCountOptions | null = null; + + mock.method( + db.users, + 'findAndCountAll', + (async (options: FindAndCountOptions) => { + capturedOptions = options; + return { rows: [], count: 0 }; + }) as unknown as typeof db.users.findAndCountAll, + ); + + await UsersDBApi.findAll( + { classId: '00000000-0000-4000-8000-000000000002', limit: 10, page: 0 }, + true, + {}, + ); + + assert.ok(capturedOptions, 'expected findAndCountAll to be called'); + const options = capturedOptions as FindAndCountOptions; + const where = options.where as Record; + const serializedWhere = inspect(where, { depth: 10 }); + + assert.ok( + serializedWhere.includes('FROM "class_enrollments"'), + 'expected class filtering to include enrolled students', + ); +}); diff --git a/backend/src/db/api/users.ts b/backend/src/db/api/users.ts index 9e95534..c61d910 100644 --- a/backend/src/db/api/users.ts +++ b/backend/src/db/api/users.ts @@ -1,9 +1,12 @@ import crypto from 'crypto'; import { Op, + literal, + col, type Includeable, type InferAttributes, type InferCreationAttributes, + type OrderItem, type WhereAttributeHash, } from 'sequelize'; import db from '@/db/models'; @@ -35,6 +38,8 @@ type UsersInputData = Partial> & { app_role?: string | null; organizations?: string | null; custom_permissions?: string[]; + /** Permissions explicitly removed from the user (subtracted from the role). */ + custom_permissions_filter?: string[]; avatar?: FileInput | FileInput[] | null; }; @@ -50,21 +55,18 @@ type DateRange = Array; interface UsersFilter { limit?: number | string; page?: number | string; + query?: string; id?: string; firstName?: string; lastName?: string; phoneNumber?: string; email?: string; - password?: string; - emailVerificationToken?: string; - passwordResetToken?: string; provider?: string; - emailVerificationTokenExpiresAtRange?: DateRange; - passwordResetTokenExpiresAtRange?: DateRange; - active?: boolean | string; disabled?: boolean | string; emailVerified?: boolean | string; app_role?: string; + campusId?: string; + classId?: string; organizations?: string; custom_permissions?: string; createdAtRange?: DateRange; @@ -72,13 +74,243 @@ interface UsersFilter { sort?: string; } +type UserListSortField = + | 'name' + | 'email' + | 'phoneNumber' + | 'organization' + | 'school' + | 'campus' + | 'role'; + const NO_USER: CurrentUser = { id: null }; +const UUID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + function usersTableName(): string { const name = db.users.getTableName(); return typeof name === 'string' ? name : name.tableName; } +function currentScope(currentUser?: CurrentUser): { + level: string | null; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; + global: boolean; +} { + if (currentUser?.activeScope) { + return { + level: currentUser.activeScope.level, + organizationId: currentUser.activeScope.organizationId, + schoolId: currentUser.activeScope.schoolId, + campusId: currentUser.activeScope.campusId, + classId: currentUser.activeScope.classId, + global: false, + }; + } + + return { + level: currentUser?.app_role?.scope ?? null, + organizationId: currentUser?.organizations?.id || currentUser?.organizationId || null, + schoolId: currentUser?.schoolId ?? null, + campusId: currentUser?.campusId ?? null, + classId: currentUser?.classId ?? null, + global: currentUser?.app_role?.globalAccess === true, + }; +} + +function scopedUsersWhere(currentUser?: CurrentUser): WhereAttributeHash { + const scope = currentScope(currentUser); + + if (scope.global) { + return {}; + } + if (scope.level === 'organization' && scope.organizationId) { + return { organizationId: scope.organizationId }; + } + if (scope.level === 'school' && scope.schoolId) { + if (!UUID_RE.test(scope.schoolId)) return {}; + return { + [Op.or]: [ + { schoolId: scope.schoolId }, + { + campusId: { + [Op.in]: literal( + `(SELECT "id" FROM "campuses" WHERE "schoolId" = '${scope.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" = '${scope.schoolId}' AND "c"."deletedAt" IS NULL AND "cm"."deletedAt" IS NULL)`, + ), + }, + }, + ], + }; + } + if (scope.level === 'campus' && scope.campusId) { + if (!UUID_RE.test(scope.campusId)) return {}; + return { + [Op.or]: [ + { campusId: scope.campusId }, + { + classId: { + [Op.in]: literal( + `(SELECT "id" FROM "classes" WHERE "campusId" = '${scope.campusId}' AND "deletedAt" IS NULL)`, + ), + }, + }, + ], + }; + } + if (scope.level === 'class' && scope.classId) { + const classId = db.sequelize.escape(scope.classId); + return { + [Op.or]: [ + { classId: scope.classId }, + { + id: { + [Op.in]: literal( + `(SELECT "studentId" FROM "class_enrollments" WHERE "classId" = ${classId} AND "deletedAt" IS NULL)`, + ), + }, + }, + ], + }; + } + if (scope.organizationId) { + return { organizationId: scope.organizationId }; + } + return {}; +} + +function appendAndCondition( + where: WhereAttributeHash, + condition: unknown, +): WhereAttributeHash { + const currentAnd = (where as Record)[Op.and]; + const existingAnd = Array.isArray(currentAnd) + ? currentAnd + : currentAnd + ? [currentAnd] + : []; + + return { + ...where, + [Op.and]: [...existingAnd, condition], + }; +} + +function relatedNameExistsCondition( + tableName: string, + foreignKey: keyof Pick< + UsersFilter, + never + > | 'organizationId' | 'schoolId' | 'campusId' | 'classId' | 'app_roleId', + value: string, +) { + const escapedPattern = db.sequelize.escape(`%${value.toLowerCase()}%`); + const usersTable = usersTableName(); + + return literal( + `EXISTS ( + SELECT 1 + FROM "${tableName}" + WHERE "${tableName}"."id" = "${usersTable}"."${foreignKey}" + AND "${tableName}"."deletedAt" IS NULL + AND lower("${tableName}"."name") LIKE ${escapedPattern} + )`, + ); +} + +function userSearchTokenCondition(token: string) { + return { + [Op.or]: [ + Utils.ilike('users', 'firstName', token), + Utils.ilike('users', 'lastName', token), + Utils.ilike('users', 'email', token), + Utils.ilike('users', 'phoneNumber', token), + relatedNameExistsCondition('organizations', 'organizationId', token), + relatedNameExistsCondition('schools', 'schoolId', token), + relatedNameExistsCondition('campuses', 'campusId', token), + relatedNameExistsCondition('classes', 'classId', token), + relatedNameExistsCondition('roles', 'app_roleId', token), + ], + }; +} + +function parseUserSortDirection(value: unknown): 'ASC' | 'DESC' { + return String(value).toLowerCase() === 'asc' ? 'ASC' : 'DESC'; +} + +function parseUserSortField(value: unknown): UserListSortField | null { + switch (value) { + case 'name': + case 'email': + case 'phoneNumber': + case 'organization': + case 'school': + case 'campus': + case 'role': + return value; + default: + return null; + } +} + +function userListOrder(field: unknown, sort: unknown): OrderItem[] { + const direction = parseUserSortDirection(sort); + const parsedField = parseUserSortField(field); + + switch (parsedField) { + case 'name': + return [ + [col('users.lastName'), direction], + [col('users.firstName'), direction], + [col('users.email'), direction], + ]; + case 'email': + return [[col('users.email'), direction]]; + case 'phoneNumber': + return [ + [col('users.phoneNumber'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; + case 'organization': + return [ + [col('organizations.name'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; + case 'school': + return [ + [col('school.name'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; + case 'campus': + return [ + [col('campus.name'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; + case 'role': + return [ + [col('app_role.name'), direction], + [col('users.lastName'), 'ASC'], + [col('users.firstName'), 'ASC'], + ]; + default: + return [['createdAt', 'desc']]; + } +} + /** Email is a user's login and primary contact, so it is always required. */ function requireEmail(email: string | null | undefined): string { if (!email) { @@ -114,8 +346,9 @@ class UsersDBApi { passwordResetToken: data.passwordResetToken || null, passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null, provider: data.provider || null, - importHash: data.importHash || null, campusId: data.campusId || null, + schoolId: data.schoolId || null, + classId: data.classId || null, createdById: currentUser.id, updatedById: currentUser.id, }, @@ -134,6 +367,10 @@ class UsersDBApi { await users.setCustom_permissions(data.custom_permissions || [], { transaction, }); + await users.setCustom_permissions_filter( + data.custom_permissions_filter || [], + { transaction }, + ); await FileDBApi.replaceRelationFiles( { @@ -171,7 +408,6 @@ class UsersDBApi { passwordResetToken: item.passwordResetToken || null, passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, provider: item.provider || null, - importHash: item.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), @@ -234,6 +470,8 @@ class UsersDBApi { data.passwordResetTokenExpiresAt; if (data.provider !== undefined) updatePayload.provider = data.provider; if (data.campusId !== undefined) updatePayload.campusId = data.campusId; + if (data.schoolId !== undefined) updatePayload.schoolId = data.schoolId; + if (data.classId !== undefined) updatePayload.classId = data.classId; updatePayload.updatedById = currentUser.id; @@ -252,6 +490,11 @@ class UsersDBApi { transaction, }); } + if (data.custom_permissions_filter !== undefined) { + await users.setCustom_permissions_filter(data.custom_permissions_filter, { + transaction, + }); + } await FileDBApi.replaceRelationFiles( { @@ -287,7 +530,7 @@ class UsersDBApi { const transaction = options?.transaction; // Per-request auth/session load. Authorization needs only role (+its - // permissions), per-user permissions, staff profile, and org — loaded in a + // permissions), per-user permissions, and org — loaded in a // single eager query (no per-association getter round-trips). `app_role` // carries its `permissions`, so the permission middleware reads them off // the loaded array instead of issuing another `getPermissions()` query. @@ -306,12 +549,16 @@ class UsersDBApi { }, ], }, - { model: db.staff, as: 'staff_user' }, { model: db.permissions, as: 'custom_permissions', through: { attributes: [] }, }, + { + model: db.permissions, + as: 'custom_permissions_filter', + through: { attributes: [] }, + }, { model: db.organizations, as: 'organizations' }, ], transaction, @@ -323,14 +570,28 @@ class UsersDBApi { return { ...users.get({ plain: true }), - staff_user: users.staff_user ?? [], app_role: users.app_role ?? null, app_role_permissions: users.app_role?.permissions ?? [], custom_permissions: users.custom_permissions ?? [], + custom_permissions_filter: users.custom_permissions_filter ?? [], organizations: users.organizations ?? null, }; } + static async findOrCreateSocialIdentity( + email: string, + provider: string, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + const [user] = await db.users.findOrCreate({ + where: { email, provider }, + transaction, + }); + + return user; + } + /** * Trimmed profile fetch for `GET /me` (and the signin/refresh responses): * one eager-loaded query selecting only the columns and relations the @@ -350,14 +611,18 @@ class UsersDBApi { 'email', 'firstName', 'lastName', + 'phoneNumber', 'organizationId', + 'schoolId', + 'campusId', + 'classId', 'app_roleId', ], include: [ { model: db.roles, as: 'app_role', - attributes: ['id', 'name', 'globalAccess'], + attributes: ['id', 'name', 'scope', 'globalAccess'], include: [ { model: db.permissions, @@ -374,30 +639,35 @@ class UsersDBApi { through: { attributes: [] }, }, { - model: db.organizations, - as: 'organizations', + model: db.permissions, + as: 'custom_permissions_filter', attributes: ['id', 'name'], + through: { attributes: [] }, }, { - model: db.staff, - as: 'staff_user', - attributes: [ - 'id', - 'employee_number', - 'job_title', - 'staff_type', - 'status', - 'organizationId', - 'campusId', - 'userId', - ], - include: [ - { - model: db.campuses, - as: 'campus', - attributes: ['id', 'name', 'code'], - }, - ], + model: db.organizations, + as: 'organizations', + attributes: ['id', 'name', 'logo'], + }, + { + model: db.schools, + as: 'school', + attributes: ['id', 'name', 'logo'], + }, + { + model: db.campuses, + as: 'campus', + attributes: ['id', 'name', 'code', 'logo'], + }, + { + model: db.classes, + as: 'class', + attributes: ['id', 'name', 'logo'], + }, + { + model: db.file, + as: 'avatar', + attributes: ['id', 'name', 'privateUrl', 'publicUrl'], }, ], transaction, @@ -407,21 +677,26 @@ class UsersDBApi { return null; } - const staffProfile = user.staff_user?.[0] ?? null; - return { id: user.id, email: user.email, name_prefix: user.name_prefix ?? null, firstName: user.firstName, lastName: user.lastName, + phoneNumber: user.phoneNumber ?? null, organizationId: user.organizationId, + schoolId: user.schoolId ?? null, + campusId: user.campusId ?? null, + classId: user.classId ?? null, organizations: user.organizations ?? null, + school: user.school ?? null, + campus: user.campus ?? null, + class: user.class ?? null, app_role: user.app_role ?? null, app_role_permissions: user.app_role?.permissions ?? [], custom_permissions: user.custom_permissions ?? [], - staff_user: user.staff_user ?? [], - staff_campus: staffProfile?.campus ?? null, + custom_permissions_filter: user.custom_permissions_filter ?? [], + avatar: user.avatar ?? [], }; } @@ -432,12 +707,7 @@ class UsersDBApi { ): Promise<{ rows: Users[]; count: number }> { const { limit, offset } = resolvePagination(filter.limit, filter.page); - let where: WhereAttributeHash = {}; - - const userOrganizations = options?.currentUser?.organizations?.id ?? null; - if (userOrganizations && options?.currentUser?.organizationId) { - where.organizationId = options.currentUser.organizationId; - } + let where: WhereAttributeHash = scopedUsersWhere(options?.currentUser); let include: Includeable[] = [ { @@ -465,118 +735,101 @@ class UsersDBApi { : {}, }, { model: db.organizations, as: 'organizations' }, + { model: db.schools, as: 'school', required: false }, + { model: db.campuses, as: 'campus', required: false }, + { model: db.classes, as: 'class', required: false }, { model: db.permissions, as: 'custom_permissions', required: false }, + { model: db.permissions, as: 'custom_permissions_filter', required: false }, { model: db.file, as: 'avatar' }, ]; if (filter.id) { where = { ...where, id: Utils.uuid(filter.id) }; } + if (filter.classId) { + const classId = Utils.uuid(filter.classId); + where = appendAndCondition(where, { + [Op.or]: [ + { classId }, + { + id: { + [Op.in]: literal( + `(SELECT "studentId" FROM "class_enrollments" WHERE "classId" = ${db.sequelize.escape(classId)} AND "deletedAt" IS NULL)`, + ), + }, + }, + ], + }); + } + if (filter.campusId) { + const campusId = Utils.uuid(filter.campusId); + where = appendAndCondition(where, { + [Op.or]: [ + { campusId }, + { + classId: { + [Op.in]: literal( + `(SELECT "id" FROM "classes" WHERE "campusId" = ${db.sequelize.escape(campusId)} AND "deletedAt" IS NULL)`, + ), + }, + }, + ], + }); + } if (filter.firstName) { - where = { - ...where, - [Op.and]: Utils.ilike('users', 'firstName', filter.firstName), - }; + where = appendAndCondition( + where, + Utils.ilike('users', 'firstName', filter.firstName), + ); } if (filter.lastName) { - where = { - ...where, - [Op.and]: Utils.ilike('users', 'lastName', filter.lastName), - }; + where = appendAndCondition( + where, + Utils.ilike('users', 'lastName', filter.lastName), + ); } if (filter.phoneNumber) { - where = { - ...where, - [Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber), - }; + where = appendAndCondition( + where, + Utils.ilike('users', 'phoneNumber', filter.phoneNumber), + ); } if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike('users', 'email', filter.email), - }; - } - if (filter.password) { - where = { - ...where, - [Op.and]: Utils.ilike('users', 'password', filter.password), - }; - } - if (filter.emailVerificationToken) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'emailVerificationToken', - filter.emailVerificationToken, - ), - }; - } - if (filter.passwordResetToken) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'passwordResetToken', - filter.passwordResetToken, - ), - }; + where = appendAndCondition( + where, + Utils.ilike('users', 'email', filter.email), + ); } if (filter.provider) { + where = appendAndCondition( + where, + Utils.ilike('users', 'provider', filter.provider), + ); + } + if (filter.query) { + const tokens = filter.query + .trim() + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean); + + for (const token of tokens) { + where = appendAndCondition(where, userSearchTokenCondition(token)); + } + } + if (filter.disabled !== undefined) { where = { ...where, - [Op.and]: Utils.ilike('users', 'provider', filter.provider), + disabled: filter.disabled === true || filter.disabled === 'true', }; } - if (filter.emailVerificationTokenExpiresAtRange) { - const [start, end] = filter.emailVerificationTokenExpiresAtRange; - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - emailVerificationTokenExpiresAt: { [Op.gte]: start }, - }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - emailVerificationTokenExpiresAt: { - ...(typeof where.emailVerificationTokenExpiresAt === 'object' - ? where.emailVerificationTokenExpiresAt - : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.passwordResetTokenExpiresAtRange) { - const [start, end] = filter.passwordResetTokenExpiresAtRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, passwordResetTokenExpiresAt: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - passwordResetTokenExpiresAt: { - ...(typeof where.passwordResetTokenExpiresAt === 'object' - ? where.passwordResetTokenExpiresAt - : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { + if (filter.emailVerified !== undefined) { where = { ...where, - active: filter.active === true || filter.active === 'true', + emailVerified: + filter.emailVerified === true || filter.emailVerified === 'true', }; } - if (filter.disabled) { - where = { ...where, disabled: filter.disabled }; - } - if (filter.emailVerified) { - where = { ...where, emailVerified: filter.emailVerified }; - } if (filter.organizations) { const listItems = filter.organizations .split('|') @@ -625,14 +878,11 @@ class UsersDBApi { } } - if (globalAccess) { + if (globalAccess && !options?.currentUser?.activeScope) { delete where.organizationId; } - const order: [string, string][] = - filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']]; + const order = userListOrder(filter.field, filter.sort); const { rows, count } = await db.users.findAndCountAll({ where, diff --git a/backend/src/db/initial-schema.ts b/backend/src/db/initial-schema.ts index 66d23f2..8660e59 100644 --- a/backend/src/db/initial-schema.ts +++ b/backend/src/db/initial-schema.ts @@ -1,7 +1,7 @@ // AUTO-GENERATED schema snapshot from the Sequelize models. // Source for the initial migration (see migrations/*-initial-schema.ts). export const INITIAL_SCHEMA_UP = ` -CREATE TABLE IF NOT EXISTS "users" ("id" UUID , "firstName" TEXT, "lastName" TEXT, "phoneNumber" TEXT, "email" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT false, "password" TEXT, "emailVerified" BOOLEAN NOT NULL DEFAULT false, "emailVerificationToken" TEXT, "emailVerificationTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "passwordResetToken" TEXT, "passwordResetTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "provider" TEXT, "importHash" VARCHAR(255) UNIQUE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "app_roleId" UUID, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "users" ("id" UUID , "firstName" TEXT, "lastName" TEXT, "phoneNumber" TEXT, "email" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT false, "password" TEXT, "emailVerified" BOOLEAN NOT NULL DEFAULT false, "emailVerificationToken" TEXT, "emailVerificationTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "passwordResetToken" TEXT, "passwordResetTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "provider" TEXT, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "app_roleId" UUID, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "academic_years" ("id" UUID , "name" TEXT, "start_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE, "current" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); DO 'BEGIN CREATE TYPE "public"."enum_assessment_results_grade_letter" AS ENUM(''A'', ''B'', ''C'', ''D'', ''E'', ''F'', ''P'', ''N''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "assessment_results" ("id" UUID , "score" DECIMAL, "grade_letter" "public"."enum_assessment_results_grade_letter", "remarks" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "assessmentId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS "class_subjects" ("id" UUID , "status" "public"."enum DO 'BEGIN CREATE TYPE "public"."enum_classes_status" AS ENUM(''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, "capacity" INTEGER, "status" "public"."enum_classes_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "gradeId" UUID, "homeroom_teacherId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); DO 'BEGIN CREATE TYPE "public"."enum_communication_events_event_type" AS ENUM(''meeting'', ''drill'', ''event'', ''deadline''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "targetLevel" TEXT NOT NULL DEFAULT 'campus', "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "schoolId" UUID, "classId" UUID, "canceledEventId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "content_catalog" ("id" UUID , "content_type" TEXT NOT NULL UNIQUE, "payload" JSONB NOT NULL, "active" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "files" ("id" UUID , "belongsTo" VARCHAR(255), "belongsToId" UUID, "belongsToColumn" VARCHAR(255), "name" VARCHAR(2083) NOT NULL, "sizeInBytes" INTEGER, "privateUrl" VARCHAR(2083), "publicUrl" VARCHAR(2083) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "frame_entries" ("id" UUID , "week_of" TEXT NOT NULL, "posted_date" TEXT NOT NULL, "formal" TEXT NOT NULL, "recognition" TEXT NOT NULL, "application" TEXT NOT NULL, "management" TEXT NOT NULL, "emotional" TEXT NOT NULL, "author" TEXT NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); @@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); -DO 'BEGIN CREATE TYPE "public"."enum_staff_staff_type" AS ENUM(''teacher'', ''admin'', ''support''); EXCEPTION WHEN duplicate_object THEN null; END'; -DO 'BEGIN CREATE TYPE "public"."enum_staff_status" AS ENUM(''active'', ''on_leave'', ''inactive''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "staff" ("id" UUID , "employee_number" TEXT, "job_title" TEXT, "staff_type" "public"."enum_staff_staff_type", "hire_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_staff_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "staff_attendance_records" ("id" UUID , "attendance_date" DATE NOT NULL, "status" "public"."enum_staff_attendance_records_status" NOT NULL, "note" TEXT, "user_name" TEXT NOT NULL, "user_role" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "subjects" ("id" UUID , "name" TEXT, "code" TEXT, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); @@ -50,7 +47,7 @@ DO 'BEGIN CREATE TYPE "public"."enum_timetable_periods_day_of_week" AS ENUM(''mo CREATE TABLE IF NOT EXISTS "timetable_periods" ("id" UUID , "day_of_week" "public"."enum_timetable_periods_day_of_week", "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "room" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "class_subjectId" UUID, "organizationId" UUID, "timetableId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); DO 'BEGIN CREATE TYPE "public"."enum_timetables_status" AS ENUM(''draft'', ''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "timetables" ("id" UUID , "name" TEXT, "effective_from" TIMESTAMP WITH TIME ZONE, "effective_to" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_timetables_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); -DO 'BEGIN CREATE TYPE "public"."enum_user_progress_progress_type" AS ENUM(''sign_learned'', ''zone_checkin''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_user_progress_progress_type" AS ENUM(''sign_learned'', ''zone_checkin'', ''classroom_strategy_favorite''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "user_progress" ("id" UUID , "progress_type" "public"."enum_user_progress_progress_type" NOT NULL, "item_id" TEXT NOT NULL, "value" TEXT, "score" INTEGER, "metadata" JSONB, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "walkthrough_checkins" ("id" UUID , "teacher_name" TEXT NOT NULL, "classroom" TEXT NOT NULL, "director_name" TEXT NOT NULL, "check_in_date" DATE NOT NULL, "check_in_time" TIME NOT NULL, "attitude_rating" INTEGER NOT NULL, "attitude_comment" TEXT, "classroom_management_rating" INTEGER NOT NULL, "classroom_management_comment" TEXT, "cleanliness_rating" INTEGER NOT NULL, "cleanliness_comment" TEXT, "vibes_rating" INTEGER NOT NULL, "vibes_comment" TEXT, "team_dynamics_rating" INTEGER NOT NULL, "team_dynamics_comment" TEXT, "emergency_exit_rating" INTEGER NOT NULL, "emergency_exit_comment" TEXT, "lesson_plan_rating" INTEGER NOT NULL, "lesson_plan_comment" TEXT, "overall_notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "roles_permissionsId" UUID , "permissionId" UUID , PRIMARY KEY ("roles_permissionsId","permissionId")); @@ -66,7 +63,6 @@ DROP TABLE IF EXISTS "timetables" CASCADE; DROP TABLE IF EXISTS "timetable_periods" CASCADE; DROP TABLE IF EXISTS "subjects" CASCADE; DROP TABLE IF EXISTS "staff_attendance_records" CASCADE; -DROP TABLE IF EXISTS "staff" CASCADE; DROP TABLE IF EXISTS "safety_quiz_results" CASCADE; DROP TABLE IF EXISTS "roles" CASCADE; DROP TABLE IF EXISTS "personality_quiz_results" CASCADE; @@ -106,8 +102,6 @@ DROP TYPE IF EXISTS "public"."enum_message_recipients_delivery_status"; DROP TYPE IF EXISTS "public"."enum_messages_channel"; DROP TYPE IF EXISTS "public"."enum_messages_audience"; DROP TYPE IF EXISTS "public"."enum_messages_status"; -DROP TYPE IF EXISTS "public"."enum_staff_staff_type"; -DROP TYPE IF EXISTS "public"."enum_staff_status"; DROP TYPE IF EXISTS "public"."enum_staff_attendance_records_status"; DROP TYPE IF EXISTS "public"."enum_timetable_periods_day_of_week"; DROP TYPE IF EXISTS "public"."enum_timetables_status"; diff --git a/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts b/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts index 2c9ae07..4fd25b9 100644 --- a/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts +++ b/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts @@ -46,13 +46,30 @@ async function columnIsNullable( export default { up: async (queryInterface: QueryInterface) => { - // Create enum type if not exists - await queryInterface.sequelize.query(` - DO 'BEGIN - CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')}); - EXCEPTION WHEN duplicate_object THEN null; END'; + // Create enum type if not exists, or add missing values to existing enum + const [enumExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM pg_type WHERE typname = 'enum_roles_scope' `); + if ((enumExists as unknown[]).length === 0) { + // Enum doesn't exist, create it with all values + await queryInterface.sequelize.query(` + CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `'${v}'`).join(', ')}); + `); + } else { + // Enum exists, add any missing values + for (const scope of ROLE_SCOPE_VALUES) { + const [valueExists] = await queryInterface.sequelize.query(` + SELECT 1 FROM pg_enum WHERE enumtypid = 'enum_roles_scope'::regtype AND enumlabel = '${scope}' + `); + if ((valueExists as unknown[]).length === 0) { + await queryInterface.sequelize.query(` + ALTER TYPE "public"."enum_roles_scope" ADD VALUE IF NOT EXISTS '${scope}' + `); + } + } + } + const scopeExists = await columnExists(queryInterface, 'roles', 'scope'); if (!scopeExists) { diff --git a/backend/src/db/migrations/20260612010000-add-schools-tier.ts b/backend/src/db/migrations/20260612010000-add-schools-tier.ts index 9e3f93d..73803bb 100644 --- a/backend/src/db/migrations/20260612010000-add-schools-tier.ts +++ b/backend/src/db/migrations/20260612010000-add-schools-tier.ts @@ -2,8 +2,8 @@ import { DataTypes, type QueryInterface } from 'sequelize'; /** * School tier (American Organization → School → Campus hierarchy). Adds the - * `schools` table and a nullable `schoolId` foreign key on `campuses`, `users`, - * and `staff`. Like every other relation in this codebase the link is an + * `schools` table and a nullable `schoolId` foreign key on `campuses` and + * `users`. Like every other relation in this codebase the link is an * app-level UUID column with no DB-level constraint (`constraints: false` in the * models). `schoolId` is left nullable here; the reseed assigns every campus to * a school (campus belongs to exactly one school). Idempotent: the table and @@ -80,13 +80,9 @@ export default { await addSchoolIdColumn(queryInterface, 'campuses'); await addSchoolIdColumn(queryInterface, 'users'); - await addSchoolIdColumn(queryInterface, 'staff'); }, down: async (queryInterface: QueryInterface) => { - if (await columnExists(queryInterface, 'staff', 'schoolId')) { - await queryInterface.removeColumn('staff', 'schoolId'); - } if (await columnExists(queryInterface, 'users', 'schoolId')) { await queryInterface.removeColumn('users', 'schoolId'); } diff --git a/backend/src/db/migrations/20260612020000-add-class-scope.ts b/backend/src/db/migrations/20260612020000-add-class-scope.ts new file mode 100644 index 0000000..a764154 --- /dev/null +++ b/backend/src/db/migrations/20260612020000-add-class-scope.ts @@ -0,0 +1,43 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Class tier (Organization → School → Campus → Class). Adds a nullable `classId` + * foreign key on `users` for class-bound roles (Teacher / Support Staff). + * Like every other relation here the link is an app-level UUID with no + * DB-level constraint. Idempotent: each column is only added if missing. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +async function addClassIdColumn( + queryInterface: QueryInterface, + table: string, +): Promise { + if (!(await columnExists(queryInterface, table, 'classId'))) { + await queryInterface.addColumn(table, 'classId', { + type: DataTypes.UUID, + allowNull: true, + }); + } +} + +export default { + up: async (queryInterface: QueryInterface) => { + await addClassIdColumn(queryInterface, 'users'); + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'users', 'classId')) { + await queryInterface.removeColumn('users', 'classId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612030000-add-tenant-logo.ts b/backend/src/db/migrations/20260612030000-add-tenant-logo.ts new file mode 100644 index 0000000..72feceb --- /dev/null +++ b/backend/src/db/migrations/20260612030000-add-tenant-logo.ts @@ -0,0 +1,49 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Tenant branding: a `logo` URL on every tenant level (organizations, schools, + * campuses, classes) for the dynamic sidebar/top-bar badge and the tenant + * creation forms. Nullable text (a stored file `privateUrl`/download URL or an + * external URL). Idempotent: only added if missing. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +async function addLogoColumn( + queryInterface: QueryInterface, + table: string, +): Promise { + if (!(await columnExists(queryInterface, table, 'logo'))) { + await queryInterface.addColumn(table, 'logo', { + type: DataTypes.TEXT, + allowNull: true, + }); + } +} + +const TABLES = ['organizations', 'schools', 'campuses', 'classes'] as const; + +export default { + up: async (queryInterface: QueryInterface) => { + for (const table of TABLES) { + await addLogoColumn(queryInterface, table); + } + }, + + down: async (queryInterface: QueryInterface) => { + for (const table of TABLES) { + if (await columnExists(queryInterface, table, 'logo')) { + await queryInterface.removeColumn(table, 'logo'); + } + } + }, +}; diff --git a/backend/src/db/migrations/20260612040000-add-guardian-students.ts b/backend/src/db/migrations/20260612040000-add-guardian-students.ts new file mode 100644 index 0000000..c10f0f1 --- /dev/null +++ b/backend/src/db/migrations/20260612040000-add-guardian-students.ts @@ -0,0 +1,46 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Guardian ↔ student link (many-to-many). Students and guardians are users + * (roles); this junction relates two `users` rows so a student can have several + * guardians and a guardian several students. App-level UUIDs, no DB-level FK + * constraints (consistent with the rest of the schema). Idempotent. + */ +async function tableExists( + queryInterface: QueryInterface, + table: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables WHERE table_name = '${table}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await tableExists(queryInterface, 'guardian_students'))) { + await queryInterface.createTable('guardian_students', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + guardianId: { type: DataTypes.UUID, allowNull: false }, + studentId: { type: DataTypes.UUID, allowNull: false }, + relationship: { type: DataTypes.TEXT, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + 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 }, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await tableExists(queryInterface, 'guardian_students')) { + await queryInterface.dropTable('guardian_students'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612060000-frame-entries-tenant.ts b/backend/src/db/migrations/20260612060000-frame-entries-tenant.ts new file mode 100644 index 0000000..ae6f5b2 --- /dev/null +++ b/backend/src/db/migrations/20260612060000-frame-entries-tenant.ts @@ -0,0 +1,45 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Per-tenant FRAME content (WS-B). FRAME entries become dedicated per tenant + * level — each of org / school / campus / class owns its own entries. Adds + * nullable `schoolId` and `classId`; the owning tenant is the most specific + * non-null of (classId, campusId, schoolId, organizationId). Idempotent. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'frame_entries', 'schoolId'))) { + await queryInterface.addColumn('frame_entries', 'schoolId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + if (!(await columnExists(queryInterface, 'frame_entries', 'classId'))) { + await queryInterface.addColumn('frame_entries', 'classId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'frame_entries', 'classId')) { + await queryInterface.removeColumn('frame_entries', 'classId'); + } + if (await columnExists(queryInterface, 'frame_entries', 'schoolId')) { + await queryInterface.removeColumn('frame_entries', 'schoolId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612070000-policy-documents-tenant.ts b/backend/src/db/migrations/20260612070000-policy-documents-tenant.ts new file mode 100644 index 0000000..8b8f50b --- /dev/null +++ b/backend/src/db/migrations/20260612070000-policy-documents-tenant.ts @@ -0,0 +1,45 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Per-tenant policy documents (WS-B). Documents become dedicated per tenant + * level — each of org / school / campus / class owns its own. Adds nullable + * `schoolId` and `classId`; the owning tenant is the most specific non-null of + * (classId, campusId, schoolId, organizationId). Idempotent. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'policy_documents', 'schoolId'))) { + await queryInterface.addColumn('policy_documents', 'schoolId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + if (!(await columnExists(queryInterface, 'policy_documents', 'classId'))) { + await queryInterface.addColumn('policy_documents', 'classId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'policy_documents', 'classId')) { + await queryInterface.removeColumn('policy_documents', 'classId'); + } + if (await columnExists(queryInterface, 'policy_documents', 'schoolId')) { + await queryInterface.removeColumn('policy_documents', 'schoolId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612080000-content-catalog-tenant.ts b/backend/src/db/migrations/20260612080000-content-catalog-tenant.ts new file mode 100644 index 0000000..df51552 --- /dev/null +++ b/backend/src/db/migrations/20260612080000-content-catalog-tenant.ts @@ -0,0 +1,56 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Per-tenant safety-quiz content (WS-B). The safety-quiz `content_type` becomes + * dedicated per tenant (org/school/campus/class); other content types stay + * org-level. Adds nullable tenant columns and drops the `content_type` UNIQUE + * constraint (a tenant-scoped type has one row per owning tenant). Idempotent. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +const COLUMNS = ['organizationId', 'schoolId', 'campusId', 'classId'] as const; + +export default { + up: async (queryInterface: QueryInterface) => { + for (const column of COLUMNS) { + if (!(await columnExists(queryInterface, 'content_catalog', column))) { + await queryInterface.addColumn('content_catalog', column, { + type: DataTypes.UUID, + allowNull: true, + }); + } + } + // Drop the UNIQUE constraint on content_type if present (name may vary). + await queryInterface.sequelize.query(` + DO $$ + DECLARE c text; + BEGIN + FOR c IN + SELECT conname FROM pg_constraint + WHERE conrelid = 'content_catalog'::regclass AND contype = 'u' + AND pg_get_constraintdef(oid) ILIKE '%(content_type)%' + LOOP + EXECUTE 'ALTER TABLE content_catalog DROP CONSTRAINT ' || quote_ident(c); + END LOOP; + END $$; + `); + }, + + down: async (queryInterface: QueryInterface) => { + for (const column of COLUMNS) { + if (await columnExists(queryInterface, 'content_catalog', column)) { + await queryInterface.removeColumn('content_catalog', column); + } + } + }, +}; diff --git a/backend/src/db/migrations/20260612090000-walkthrough-tenant.ts b/backend/src/db/migrations/20260612090000-walkthrough-tenant.ts new file mode 100644 index 0000000..c48a4bc --- /dev/null +++ b/backend/src/db/migrations/20260612090000-walkthrough-tenant.ts @@ -0,0 +1,45 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Exact-tenant walk-through check-ins (WS, scope map). A boss evaluates only + * their own tenant's staff — org/school/campus each own theirs. Adds nullable + * `schoolId` and `classId` (the owning tenant is the most specific non-null of + * (classId, campusId, schoolId, organizationId)). Idempotent. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'walkthrough_checkins', 'schoolId'))) { + await queryInterface.addColumn('walkthrough_checkins', 'schoolId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + if (!(await columnExists(queryInterface, 'walkthrough_checkins', 'classId'))) { + await queryInterface.addColumn('walkthrough_checkins', 'classId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'walkthrough_checkins', 'classId')) { + await queryInterface.removeColumn('walkthrough_checkins', 'classId'); + } + if (await columnExists(queryInterface, 'walkthrough_checkins', 'schoolId')) { + await queryInterface.removeColumn('walkthrough_checkins', 'schoolId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612100000-communication-events-tenant.ts b/backend/src/db/migrations/20260612100000-communication-events-tenant.ts new file mode 100644 index 0000000..9466536 --- /dev/null +++ b/backend/src/db/migrations/20260612100000-communication-events-tenant.ts @@ -0,0 +1,45 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Exact-tenant internal alerts (scope map). Internal alerts + * (`communication_events`) are dedicated per tenant — a campus doesn't see + * org-level alerts and vice-versa. Adds nullable `schoolId` and `classId`. + * Idempotent. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'communication_events', 'schoolId'))) { + await queryInterface.addColumn('communication_events', 'schoolId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + if (!(await columnExists(queryInterface, 'communication_events', 'classId'))) { + await queryInterface.addColumn('communication_events', 'classId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'communication_events', 'classId')) { + await queryInterface.removeColumn('communication_events', 'classId'); + } + if (await columnExists(queryInterface, 'communication_events', 'schoolId')) { + await queryInterface.removeColumn('communication_events', 'schoolId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612110000-add-class-attendance.ts b/backend/src/db/migrations/20260612110000-add-class-attendance.ts new file mode 100644 index 0000000..31ed434 --- /dev/null +++ b/backend/src/db/migrations/20260612110000-add-class-attendance.ts @@ -0,0 +1,53 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Class-level daily attendance (WS-C). The teacher fills a per-class aggregate + * that rolls up to campus → school → org. The owning class's campus/school/org + * are denormalized onto the row so a tier rollup is a single-column SUM filter. + * Idempotent. + */ +async function tableExists( + queryInterface: QueryInterface, + table: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables WHERE table_name = '${table}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await tableExists(queryInterface, 'class_attendance'))) { + await queryInterface.createTable('class_attendance', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + classId: { type: DataTypes.UUID, allowNull: false }, + attendance_date: { type: DataTypes.DATEONLY, allowNull: false }, + total_enrolled: { type: DataTypes.INTEGER, allowNull: false }, + total_present: { type: DataTypes.INTEGER, allowNull: false }, + total_absent: { type: DataTypes.INTEGER, allowNull: false }, + total_tardy: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + attendance_percentage: { type: DataTypes.DECIMAL(5, 2), allowNull: true }, + recorded_by_label: { type: DataTypes.TEXT, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + 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 }, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await tableExists(queryInterface, 'class_attendance')) { + await queryInterface.dropTable('class_attendance'); + } + }, +}; diff --git a/backend/src/db/migrations/20260612120000-add-direct-messages.ts b/backend/src/db/migrations/20260612120000-add-direct-messages.ts new file mode 100644 index 0000000..a11e4d1 --- /dev/null +++ b/backend/src/db/migrations/20260612120000-add-direct-messages.ts @@ -0,0 +1,51 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Direct 1:1 messages between a staff member (teacher / office_manager) and a + * guardian. A conversation is the unordered pair {senderId, recipientId}; both + * parties are discovered through a shared student (`studentId` is the connecting + * context). Access is membership-based — a user only ever reads rows where they + * are the sender or the recipient, so conversations are naturally isolated. + * App-level UUIDs, no DB-level FK constraints (consistent with the schema). + * Idempotent. + */ +async function tableExists( + queryInterface: QueryInterface, + table: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.tables WHERE table_name = '${table}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await tableExists(queryInterface, 'direct_messages'))) { + await queryInterface.createTable('direct_messages', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + senderId: { type: DataTypes.UUID, allowNull: false }, + recipientId: { type: DataTypes.UUID, allowNull: false }, + studentId: { type: DataTypes.UUID, allowNull: true }, + body: { type: DataTypes.TEXT, allowNull: false }, + readAt: { type: DataTypes.DATE, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + 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 }, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await tableExists(queryInterface, 'direct_messages')) { + await queryInterface.dropTable('direct_messages'); + } + }, +}; diff --git a/backend/src/db/migrations/20260614090000-drop-global-content-catalog-rows.ts b/backend/src/db/migrations/20260614090000-drop-global-content-catalog-rows.ts new file mode 100644 index 0000000..b8d7d9e --- /dev/null +++ b/backend/src/db/migrations/20260614090000-drop-global-content-catalog-rows.ts @@ -0,0 +1,26 @@ +import { Op, type QueryInterface } from 'sequelize'; + +const GLOBAL_STATIC_CONTENT_TYPES = Object.freeze([ + 'classroom-timer-backgrounds', + 'classroom-timer-sounds', + 'classroom-timer-presets', + 'classroom-timer-tips', + 'personality-quiz-questions', + 'personality-types', + 'personality-quiz-features', + 'personality-workplace-content', +]); + +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete('content_catalog', { + content_type: { + [Op.in]: GLOBAL_STATIC_CONTENT_TYPES, + }, + }); + }, + + down: async () => { + // Intentionally no-op: these records moved to frontend static constants. + }, +}; diff --git a/backend/src/db/migrations/20260614100000-feature-management-permissions.ts b/backend/src/db/migrations/20260614100000-feature-management-permissions.ts new file mode 100644 index 0000000..f769d3d --- /dev/null +++ b/backend/src/db/migrations/20260614100000-feature-management-permissions.ts @@ -0,0 +1,155 @@ +import { v4 as uuid } from 'uuid'; +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; + +const PERMISSIONS = Object.freeze([ + FEATURE_PERMISSIONS.MANAGE_FRAME, + FEATURE_PERMISSIONS.MANAGE_WALKTHROUGH, + FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM, + FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG, + FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS, + FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS, + FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS, + FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS, +]); + +const FULL_ACCESS_ROLES: readonly RoleName[] = Object.freeze([ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.DIRECTOR, +]); + +const REPORT_PERMISSIONS = Object.freeze([ + FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS, + FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS, + FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS, + FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS, +]); + +const ROLE_GRANTS: Readonly> = Object.freeze({ + [ROLE_NAMES.SUPER_ADMIN]: [], + [ROLE_NAMES.SYSTEM_ADMIN]: [], + [ROLE_NAMES.OWNER]: PERMISSIONS, + [ROLE_NAMES.SUPERINTENDENT]: PERMISSIONS, + [ROLE_NAMES.PRINCIPAL]: PERMISSIONS, + [ROLE_NAMES.REGISTRAR]: REPORT_PERMISSIONS, + [ROLE_NAMES.DIRECTOR]: PERMISSIONS, + [ROLE_NAMES.OFFICE_MANAGER]: [], + [ROLE_NAMES.TEACHER]: [], + [ROLE_NAMES.SUPPORT_STAFF]: [], + [ROLE_NAMES.STUDENT]: [], + [ROLE_NAMES.GUARDIAN]: [], + [ROLE_NAMES.GUEST]: [], +}); + +function isNamedIdRow(value: unknown): value is { id: string; name: string } { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + && 'name' in value + && typeof value.name === 'string' + ); +} + +function resultRows(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +async function rowsByName( + queryInterface: QueryInterface, + table: string, + names: readonly string[], +): Promise> { + const [rows] = await queryInterface.sequelize.query( + `SELECT "id", "name" FROM "${table}" WHERE "name" IN (:names)`, + { replacements: { names } }, + ); + return new Map( + resultRows(rows) + .filter(isNamedIdRow) + .map((row) => [row.name, row.id]), + ); +} + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const permissionIds = await rowsByName(queryInterface, 'permissions', PERMISSIONS); + const missing = PERMISSIONS.filter((name) => !permissionIds.has(name)); + + if (missing.length > 0) { + const rows = missing.map((name) => { + const id = uuid(); + permissionIds.set(name, id); + return { id, name, createdAt: now, updatedAt: now }; + }); + await queryInterface.bulkInsert('permissions', rows); + } + + const roleIds = await rowsByName(queryInterface, 'roles', Object.values(ROLE_NAMES)); + const links: Array<{ + createdAt: Date; + updatedAt: Date; + roles_permissionsId: string; + permissionId: string; + }> = []; + + for (const role of FULL_ACCESS_ROLES) { + const roleId = roleIds.get(role); + if (!roleId) continue; + for (const permissionName of ROLE_GRANTS[role]) { + const permissionId = permissionIds.get(permissionName); + if (!permissionId) continue; + const [existing] = await queryInterface.sequelize.query( + `SELECT 1 FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId + LIMIT 1`, + { replacements: { roleId, permissionId } }, + ); + if (resultRows(existing).length === 0) { + links.push({ + createdAt: now, + updatedAt: now, + roles_permissionsId: roleId, + permissionId, + }); + } + } + } + + const registrarId = roleIds.get(ROLE_NAMES.REGISTRAR); + if (registrarId) { + for (const permissionName of REPORT_PERMISSIONS) { + const permissionId = permissionIds.get(permissionName); + if (!permissionId) continue; + const [existing] = await queryInterface.sequelize.query( + `SELECT 1 FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId + LIMIT 1`, + { replacements: { roleId: registrarId, permissionId } }, + ); + if (resultRows(existing).length === 0) { + links.push({ + createdAt: now, + updatedAt: now, + roles_permissionsId: registrarId, + permissionId, + }); + } + } + } + + if (links.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', links); + } + }, + + down: async () => { + // Intentionally no-op: permission rows are safe to keep and may be assigned + // as per-user custom permissions. + }, +}; diff --git a/backend/src/db/migrations/20260615150000-global-communication-events.ts b/backend/src/db/migrations/20260615150000-global-communication-events.ts new file mode 100644 index 0000000..7a4b4e7 --- /dev/null +++ b/backend/src/db/migrations/20260615150000-global-communication-events.ts @@ -0,0 +1,25 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Platform-scope internal alerts are owned by the global scope, represented by + * a null tenant chain. Tenant alerts still carry organization/school/campus ids. + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.changeColumn('communication_events', 'organizationId', { + type: DataTypes.UUID, + allowNull: true, + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.sequelize.query(` + DELETE FROM "communication_events" + WHERE "organizationId" IS NULL + `); + await queryInterface.changeColumn('communication_events', 'organizationId', { + type: DataTypes.UUID, + allowNull: false, + }); + }, +}; diff --git a/backend/src/db/migrations/20260615162000-communication-event-target-level.ts b/backend/src/db/migrations/20260615162000-communication-event-target-level.ts new file mode 100644 index 0000000..a318012 --- /dev/null +++ b/backend/src/db/migrations/20260615162000-communication-event-target-level.ts @@ -0,0 +1,36 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Internal alerts target exact audience tiers. The tenant ids identify the + * selected organization/school/campus; targetLevel distinguishes platform + * system-only alerts from platform all-user broadcasts. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'communication_events', 'targetLevel'))) { + await queryInterface.addColumn('communication_events', 'targetLevel', { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'campus', + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'communication_events', 'targetLevel')) { + await queryInterface.removeColumn('communication_events', 'targetLevel'); + } + }, +}; diff --git a/backend/src/db/migrations/20260615183000-communication-event-cancellations.ts b/backend/src/db/migrations/20260615183000-communication-event-cancellations.ts new file mode 100644 index 0000000..7be291d --- /dev/null +++ b/backend/src/db/migrations/20260615183000-communication-event-cancellations.ts @@ -0,0 +1,30 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'communication_events', 'canceledEventId'))) { + await queryInterface.addColumn('communication_events', 'canceledEventId', { + type: DataTypes.UUID, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'communication_events', 'canceledEventId')) { + await queryInterface.removeColumn('communication_events', 'canceledEventId'); + } + }, +}; diff --git a/backend/src/db/migrations/20260616110000-move-staff-profile-fields-to-users.ts b/backend/src/db/migrations/20260616110000-move-staff-profile-fields-to-users.ts new file mode 100644 index 0000000..eb29694 --- /dev/null +++ b/backend/src/db/migrations/20260616110000-move-staff-profile-fields-to-users.ts @@ -0,0 +1,31 @@ +import { type QueryInterface } from 'sequelize'; + +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; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (await tableExists(queryInterface, 'staff')) { + await queryInterface.dropTable('staff'); + } + + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "public"."enum_staff_staff_type"', + ); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "public"."enum_staff_status"', + ); + }, + + down: async (queryInterface: QueryInterface) => { + void queryInterface; + }, +}; diff --git a/backend/src/db/migrations/20260616153000-grant-system-admin-all-permissions.ts b/backend/src/db/migrations/20260616153000-grant-system-admin-all-permissions.ts new file mode 100644 index 0000000..f6464b5 --- /dev/null +++ b/backend/src/db/migrations/20260616153000-grant-system-admin-all-permissions.ts @@ -0,0 +1,94 @@ +import type { QueryInterface } from 'sequelize'; +import { ROLE_NAMES } from '@/shared/constants/roles'; + +type NamedRow = { + readonly id: string; + readonly name: string; +}; + +function isNamedRow(value: unknown): value is NamedRow { + return ( + value !== null + && typeof value === 'object' + && 'id' in value + && typeof value.id === 'string' + && 'name' in value + && typeof value.name === 'string' + ); +} + +function rowsOf(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +export default { + async up(queryInterface: QueryInterface) { + const now = new Date(); + const [roleRows] = await queryInterface.sequelize.query( + `SELECT "id", "name" FROM "roles" WHERE "name" = :name LIMIT 1`, + { replacements: { name: ROLE_NAMES.SYSTEM_ADMIN } }, + ); + const systemAdminRole = rowsOf(roleRows).find(isNamedRow); + + if (!systemAdminRole) { + return; + } + + const [permissionRows] = await queryInterface.sequelize.query( + `SELECT "id", "name" FROM "permissions"`, + ); + const permissions = rowsOf(permissionRows).filter(isNamedRow); + + if (permissions.length === 0) { + return; + } + + const [existingRows] = await queryInterface.sequelize.query( + `SELECT "permissionId" + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId`, + { replacements: { roleId: systemAdminRole.id } }, + ); + const existingPermissionIds = new Set( + rowsOf(existingRows) + .map((row) => + row !== null + && typeof row === 'object' + && 'permissionId' in row + && typeof row.permissionId === 'string' + ? row.permissionId + : null, + ) + .filter((id): id is string => id !== null), + ); + + const links = permissions + .filter((permission) => !existingPermissionIds.has(permission.id)) + .map((permission) => ({ + createdAt: now, + updatedAt: now, + roles_permissionsId: systemAdminRole.id, + permissionId: permission.id, + })); + + if (links.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', links); + } + }, + + async down(queryInterface: QueryInterface) { + const [roleRows] = await queryInterface.sequelize.query( + `SELECT "id", "name" FROM "roles" WHERE "name" = :name LIMIT 1`, + { replacements: { name: ROLE_NAMES.SYSTEM_ADMIN } }, + ); + const systemAdminRole = rowsOf(roleRows).find(isNamedRow); + + if (!systemAdminRole) { + return; + } + + await queryInterface.bulkDelete('rolesPermissionsPermissions', { + roles_permissionsId: systemAdminRole.id, + }); + }, +}; diff --git a/backend/src/db/migrations/20260616170000-backfill-dashboard-content-scopes.ts b/backend/src/db/migrations/20260616170000-backfill-dashboard-content-scopes.ts new file mode 100644 index 0000000..1d5846e --- /dev/null +++ b/backend/src/db/migrations/20260616170000-backfill-dashboard-content-scopes.ts @@ -0,0 +1,147 @@ +import { Op, QueryTypes, type QueryInterface } from 'sequelize'; +import { v4 as uuid } from 'uuid'; +import type { ContentCatalog } from '@/db/models/content_catalog'; +import type { CreationAttributes } from 'sequelize'; +import { PER_TENANT_CONTENT_TYPES } from '@/shared/constants/content-catalog'; +import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads'; + +const BACKFILL_IMPORT_HASH_PREFIX = 'dashboard-content-scope-backfill'; + +interface TenantRow { + readonly id: string; + readonly organizationId?: string | null; +} + +interface ContentStamp { + readonly level: 'organization' | 'school' | 'campus'; + readonly organizationId: string; + readonly schoolId: string | null; + readonly campusId: string | null; +} + +const perTenantDefaultRows = CONTENT_CATALOG_DEFAULT_ROWS.filter((row) => + PER_TENANT_CONTENT_TYPES.has(row.content_type), +); + +async function exactContentRowExists( + queryInterface: QueryInterface, + contentType: string, + stamp: ContentStamp, +): Promise { + const rows = await queryInterface.sequelize.query<{ id: string }>( + ` + SELECT id + FROM content_catalog + WHERE content_type = :contentType + AND active = true + AND "deletedAt" IS NULL + AND "organizationId" = :organizationId + AND "schoolId" ${stamp.schoolId ? '= :schoolId' : 'IS NULL'} + AND "campusId" ${stamp.campusId ? '= :campusId' : 'IS NULL'} + AND "classId" IS NULL + LIMIT 1 + `, + { + replacements: { + contentType, + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + }, + type: QueryTypes.SELECT, + }, + ); + + return rows.length > 0; +} + +function importHash(contentType: string, stamp: ContentStamp): string { + return [ + BACKFILL_IMPORT_HASH_PREFIX, + contentType, + stamp.level, + stamp.campusId ?? stamp.schoolId ?? stamp.organizationId, + ].join('-'); +} + +export default { + up: async (queryInterface: QueryInterface) => { + const [organizations, schools, campuses] = await Promise.all([ + queryInterface.sequelize.query( + 'SELECT id FROM organizations WHERE "deletedAt" IS NULL', + { type: QueryTypes.SELECT }, + ), + queryInterface.sequelize.query( + 'SELECT id, "organizationId" FROM schools WHERE "deletedAt" IS NULL', + { type: QueryTypes.SELECT }, + ), + queryInterface.sequelize.query( + 'SELECT id, "organizationId" FROM campuses WHERE "deletedAt" IS NULL', + { type: QueryTypes.SELECT }, + ), + ]); + + const stamps: ContentStamp[] = [ + ...organizations.map((organization) => ({ + level: 'organization' as const, + organizationId: organization.id, + schoolId: null, + campusId: null, + })), + ...schools + .filter((school): school is TenantRow & { readonly organizationId: string } => + typeof school.organizationId === 'string', + ) + .map((school) => ({ + level: 'school' as const, + organizationId: school.organizationId, + schoolId: school.id, + campusId: null, + })), + ...campuses + .filter((campus): campus is TenantRow & { readonly organizationId: string } => + typeof campus.organizationId === 'string', + ) + .map((campus) => ({ + level: 'campus' as const, + organizationId: campus.organizationId, + schoolId: null, + campusId: campus.id, + })), + ]; + + const now = new Date(); + const rows: CreationAttributes[] = []; + + for (const contentRow of perTenantDefaultRows) { + for (const stamp of stamps) { + if (await exactContentRowExists(queryInterface, contentRow.content_type, stamp)) { + continue; + } + rows.push({ + id: uuid(), + content_type: contentRow.content_type, + payload: JSON.stringify(contentRow.payload), + active: true, + importHash: importHash(contentRow.content_type, stamp), + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: null, + createdAt: now, + updatedAt: now, + }); + } + } + + if (rows.length > 0) { + await queryInterface.bulkInsert('content_catalog', rows); + } + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete('content_catalog', { + importHash: { [Op.like]: `${BACKFILL_IMPORT_HASH_PREFIX}%` }, + }); + }, +}; diff --git a/backend/src/db/migrations/20260617100000-drop-users-staff-type.ts b/backend/src/db/migrations/20260617100000-drop-users-staff-type.ts new file mode 100644 index 0000000..a3e0b80 --- /dev/null +++ b/backend/src/db/migrations/20260617100000-drop-users-staff-type.ts @@ -0,0 +1,28 @@ +import { type QueryInterface } from 'sequelize'; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'users', 'staff_type')) { + await queryInterface.removeColumn('users', 'staff_type'); + } + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "public"."enum_staff_staff_type"', + ); + }, + + down: async () => { + // Pre-launch cleanup: staff classification is role + scope only. + }, +}; diff --git a/backend/src/db/migrations/20260617101000-drop-users-job-title-hire-date.ts b/backend/src/db/migrations/20260617101000-drop-users-job-title-hire-date.ts new file mode 100644 index 0000000..5358361 --- /dev/null +++ b/backend/src/db/migrations/20260617101000-drop-users-job-title-hire-date.ts @@ -0,0 +1,29 @@ +import { type QueryInterface } from 'sequelize'; + +const DROPPED_USER_COLUMNS = ['job_title', 'hire_date'] as const; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + for (const column of DROPPED_USER_COLUMNS) { + if (await columnExists(queryInterface, 'users', column)) { + await queryInterface.removeColumn('users', column); + } + } + }, + + down: async () => { + // Pre-launch cleanup: role + scope are the canonical user classification. + }, +}; diff --git a/backend/src/db/migrations/20260617102000-drop-users-employee-number-status.ts b/backend/src/db/migrations/20260617102000-drop-users-employee-number-status.ts new file mode 100644 index 0000000..b68fc6d --- /dev/null +++ b/backend/src/db/migrations/20260617102000-drop-users-employee-number-status.ts @@ -0,0 +1,29 @@ +import { type QueryInterface } from 'sequelize'; + +const DROPPED_USER_COLUMNS = ['employee_number', 'status'] as const; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + for (const column of DROPPED_USER_COLUMNS) { + if (await columnExists(queryInterface, 'users', column)) { + await queryInterface.removeColumn('users', column); + } + } + }, + + down: async () => { + // Pre-launch cleanup: user identity is role + scope, not HR metadata. + }, +}; diff --git a/backend/src/db/migrations/20260617103000-drop-users-import-hash.ts b/backend/src/db/migrations/20260617103000-drop-users-import-hash.ts new file mode 100644 index 0000000..ef3119f --- /dev/null +++ b/backend/src/db/migrations/20260617103000-drop-users-import-hash.ts @@ -0,0 +1,25 @@ +import { type QueryInterface } from 'sequelize'; + +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (await columnExists(queryInterface, 'users', 'importHash')) { + await queryInterface.removeColumn('users', 'importHash'); + } + }, + + down: async () => { + // Pre-launch cleanup: users are created through auth/provisioning, not import hashes. + }, +}; diff --git a/backend/src/db/migrations/20260617104000-add-classroom-strategy-favorite-progress.ts b/backend/src/db/migrations/20260617104000-add-classroom-strategy-favorite-progress.ts new file mode 100644 index 0000000..4fc0d43 --- /dev/null +++ b/backend/src/db/migrations/20260617104000-add-classroom-strategy-favorite-progress.ts @@ -0,0 +1,15 @@ +import type { QueryInterface } from 'sequelize'; + +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.sequelize.query(` + ALTER TYPE "public"."enum_user_progress_progress_type" + ADD VALUE IF NOT EXISTS 'classroom_strategy_favorite'; + `); + }, + + down: async () => { + // PostgreSQL enum values cannot be removed safely while rows may reference + // them. Keep the value in place; rollback only removes application usage. + }, +}; diff --git a/backend/src/db/models/attendance_sessions.ts b/backend/src/db/models/attendance_sessions.ts index 2b5c88e..acc30e5 100644 --- a/backend/src/db/models/attendance_sessions.ts +++ b/backend/src/db/models/attendance_sessions.ts @@ -18,7 +18,6 @@ import type { Campuses } from './campuses'; import type { ClassSubjects } from './class_subjects'; import type { Classes } from './classes'; import type { Organizations } from './organizations'; -import type { Staff } from './staff'; import type { Users } from './users'; export class AttendanceSessions extends Model< @@ -52,8 +51,8 @@ export class AttendanceSessions extends Model< declare setClass: BelongsToSetAssociationMixin; declare getClass_subject: BelongsToGetAssociationMixin; declare setClass_subject: BelongsToSetAssociationMixin; - declare getTaken_by: BelongsToGetAssociationMixin; - declare setTaken_by: BelongsToSetAssociationMixin; + declare getTaken_by: BelongsToGetAssociationMixin; + declare setTaken_by: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -136,7 +135,7 @@ export class AttendanceSessions extends Model< constraints: false, }); - db.attendance_sessions.belongsTo(db.staff, { + db.attendance_sessions.belongsTo(db.users, { as: 'taken_by', foreignKey: { name: 'taken_byId', diff --git a/backend/src/db/models/campuses.ts b/backend/src/db/models/campuses.ts index 4a14662..88895a8 100644 --- a/backend/src/db/models/campuses.ts +++ b/backend/src/db/models/campuses.ts @@ -18,7 +18,6 @@ import type { Classes } from './classes'; import type { Messages } from './messages'; import type { Organizations } from './organizations'; import type { Schools } from './schools'; -import type { Staff } from './staff'; import type { Timetables } from './timetables'; import type { Users } from './users'; import { isValidIanaTimezone } from '@/shared/constants/timezone'; @@ -41,6 +40,7 @@ export class Campuses extends Model< declare textColor: string | null; declare bgLight: string | null; declare description: string | null; + declare logo: CreationOptional; declare isOnline: CreationOptional; declare active: CreationOptional; declare importHash: CreationOptional; @@ -54,8 +54,6 @@ export class Campuses extends Model< declare deletedAt: CreationOptional; - declare getStaff_campus: HasManyGetAssociationsMixin; - declare setStaff_campus: HasManySetAssociationsMixin; declare getClasses_campus: HasManyGetAssociationsMixin; declare setClasses_campus: HasManySetAssociationsMixin; declare getTimetables_campus: HasManyGetAssociationsMixin; @@ -74,12 +72,6 @@ export class Campuses extends Model< declare setUpdatedBy: BelongsToSetAssociationMixin; static associate(db: Db): void { - db.campuses.hasMany(db.staff, { - as: 'staff_campus', - foreignKey: { name: 'campusId' }, - constraints: false, - }); - db.campuses.hasMany(db.classes, { as: 'classes_campus', foreignKey: { name: 'campusId' }, @@ -152,6 +144,7 @@ export default function (sequelize: Sequelize): typeof Campuses { textColor: { type: DataTypes.TEXT }, bgLight: { type: DataTypes.TEXT }, description: { type: DataTypes.TEXT }, + logo: { type: DataTypes.TEXT }, isOnline: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/src/db/models/class_attendance.ts b/backend/src/db/models/class_attendance.ts new file mode 100644 index 0000000..75d3c52 --- /dev/null +++ b/backend/src/db/models/class_attendance.ts @@ -0,0 +1,88 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { BelongsToGetAssociationMixin } from 'sequelize'; +import type { Classes } from './classes'; + +/** + * Class-level daily attendance aggregate (WS-C), filled by the class teacher + * (same shape as the campus aggregate). It is the **source** that rolls up to + * campus → school → org: the owning class's `campusId`/`schoolId`/ + * `organizationId` are denormalized onto the row so a tier rollup is a simple + * `SUM` with a single-column filter (no joins). One row per class per date. + */ +export class ClassAttendance extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare classId: string; + declare attendance_date: string; + declare total_enrolled: number; + declare total_present: number; + declare total_absent: number; + declare total_tardy: CreationOptional; + declare attendance_percentage: CreationOptional; + declare recorded_by_label: CreationOptional; + declare organizationId: CreationOptional; + declare schoolId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare getClass: BelongsToGetAssociationMixin; + + static associate(db: Db): void { + db.class_attendance.belongsTo(db.classes, { + as: 'class', + foreignKey: { name: 'classId' }, + constraints: false, + }); + } +} + +export default function (sequelize: Sequelize): typeof ClassAttendance { + ClassAttendance.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + classId: { type: DataTypes.UUID, allowNull: false }, + attendance_date: { type: DataTypes.DATEONLY, allowNull: false }, + total_enrolled: { type: DataTypes.INTEGER, allowNull: false }, + total_present: { type: DataTypes.INTEGER, allowNull: false }, + total_absent: { type: DataTypes.INTEGER, allowNull: false }, + total_tardy: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + attendance_percentage: { type: DataTypes.DECIMAL(5, 2), allowNull: true }, + recorded_by_label: { type: DataTypes.TEXT, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + 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: 'class_attendance', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return ClassAttendance; +} diff --git a/backend/src/db/models/class_subjects.ts b/backend/src/db/models/class_subjects.ts index 233c2a2..1059251 100644 --- a/backend/src/db/models/class_subjects.ts +++ b/backend/src/db/models/class_subjects.ts @@ -17,7 +17,6 @@ import type { Assessments } from './assessments'; import type { AttendanceSessions } from './attendance_sessions'; import type { Classes } from './classes'; import type { Organizations } from './organizations'; -import type { Staff } from './staff'; import type { Subjects } from './subjects'; import type { TimetablePeriods } from './timetable_periods'; import type { Users } from './users'; @@ -52,8 +51,8 @@ export class ClassSubjects extends Model< declare setClass: BelongsToSetAssociationMixin; declare getSubject: BelongsToGetAssociationMixin; declare setSubject: BelongsToSetAssociationMixin; - declare getTeacher: BelongsToGetAssociationMixin; - declare setTeacher: BelongsToSetAssociationMixin; + declare getTeacher: BelongsToGetAssociationMixin; + declare setTeacher: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -144,7 +143,7 @@ export class ClassSubjects extends Model< constraints: false, }); - db.class_subjects.belongsTo(db.staff, { + db.class_subjects.belongsTo(db.users, { as: 'teacher', foreignKey: { name: 'teacherId', diff --git a/backend/src/db/models/classes.ts b/backend/src/db/models/classes.ts index e390633..f9b3a03 100644 --- a/backend/src/db/models/classes.ts +++ b/backend/src/db/models/classes.ts @@ -20,7 +20,6 @@ import type { ClassEnrollments } from './class_enrollments'; import type { ClassSubjects } from './class_subjects'; import type { Grades } from './grades'; import type { Organizations } from './organizations'; -import type { Staff } from './staff'; import type { Users } from './users'; export class Classes extends Model< @@ -30,6 +29,7 @@ export class Classes extends Model< declare id: CreationOptional; declare name: string | null; declare section: string | null; + declare logo: CreationOptional; declare capacity: number | null; declare status: string | null; declare importHash: CreationOptional; @@ -59,8 +59,8 @@ export class Classes extends Model< declare setAcademic_year: BelongsToSetAssociationMixin; declare getGrade: BelongsToGetAssociationMixin; declare setGrade: BelongsToSetAssociationMixin; - declare getHomeroom_teacher: BelongsToGetAssociationMixin; - declare setHomeroom_teacher: BelongsToSetAssociationMixin; + declare getHomeroom_teacher: BelongsToGetAssociationMixin; + declare setHomeroom_teacher: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -159,7 +159,7 @@ export class Classes extends Model< constraints: false, }); - db.classes.belongsTo(db.staff, { + db.classes.belongsTo(db.users, { as: 'homeroom_teacher', foreignKey: { name: 'homeroom_teacherId', @@ -191,11 +191,13 @@ export default function (sequelize: Sequelize): typeof Classes { name: { type: DataTypes.TEXT, - - + + }, + logo: { type: DataTypes.TEXT }, + section: { type: DataTypes.TEXT, diff --git a/backend/src/db/models/communication_events.ts b/backend/src/db/models/communication_events.ts index 61e7b61..2510213 100644 --- a/backend/src/db/models/communication_events.ts +++ b/backend/src/db/models/communication_events.ts @@ -28,13 +28,18 @@ export class CommunicationEvents extends Model< declare title: string; declare event_date: string; declare event_type: CommunicationEventType; + declare targetLevel: CreationOptional; declare roles: CreationOptional; declare importHash: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; declare deletedAt: CreationOptional; - declare organizationId: CreationOptional; + declare organizationId: CreationOptional; declare campusId: CreationOptional; + /** Per-tenant owner (internal alerts are exact-tenant): one leaf is set. */ + declare schoolId: CreationOptional; + declare classId: CreationOptional; + declare canceledEventId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; @@ -95,6 +100,11 @@ export default function (sequelize: Sequelize): typeof CommunicationEvents { type: DataTypes.ENUM(...COMMUNICATION_EVENT_TYPE_VALUES), allowNull: false, }, + targetLevel: { + type: DataTypes.TEXT, + allowNull: false, + defaultValue: 'campus', + }, roles: { type: DataTypes.JSONB, allowNull: false, @@ -108,8 +118,11 @@ export default function (sequelize: Sequelize): typeof CommunicationEvents { createdAt: { type: DataTypes.DATE }, updatedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE }, - organizationId: { type: DataTypes.UUID, allowNull: false }, + organizationId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, + canceledEventId: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: false }, updatedById: { type: DataTypes.UUID, allowNull: true }, }, diff --git a/backend/src/db/models/content_catalog.ts b/backend/src/db/models/content_catalog.ts index acd6abc..03b2391 100644 --- a/backend/src/db/models/content_catalog.ts +++ b/backend/src/db/models/content_catalog.ts @@ -17,6 +17,15 @@ export class ContentCatalog extends Model< declare payload: unknown; declare active: CreationOptional; declare importHash: CreationOptional; + /** + * Per-tenant content owner for tenant-scoped content types (the safety quiz). + * Null for shared (org-level) content types. The owning tenant is the most + * specific non-null of (classId, campusId, schoolId, organizationId). + */ + declare organizationId: CreationOptional; + declare schoolId: CreationOptional; + declare campusId: CreationOptional; + declare classId: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; declare deletedAt: CreationOptional; @@ -35,9 +44,11 @@ export default function (sequelize: Sequelize): typeof ContentCatalog { primaryKey: true, }, content_type: { + // Not unique: tenant-scoped content types (the safety quiz) have one + // row per owning tenant. Uniqueness is enforced per (content_type, + // tenant) in the service. type: DataTypes.TEXT, allowNull: false, - unique: true, }, payload: { type: DataTypes.JSONB, @@ -53,6 +64,10 @@ export default function (sequelize: Sequelize): typeof ContentCatalog { allowNull: true, unique: true, }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, createdAt: { type: DataTypes.DATE }, updatedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE }, diff --git a/backend/src/db/models/direct_messages.ts b/backend/src/db/models/direct_messages.ts new file mode 100644 index 0000000..01ac415 --- /dev/null +++ b/backend/src/db/models/direct_messages.ts @@ -0,0 +1,96 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, + type BelongsToGetAssociationMixin, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +/** + * Direct 1:1 message between a staff member and a guardian. A conversation is + * the unordered pair {senderId, recipientId}; `studentId` records the student + * the two were connected through. Read access is membership-based (sender or + * recipient only), which keeps every conversation isolated. + */ +export class DirectMessages extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare senderId: string; + declare recipientId: string; + declare studentId: CreationOptional; + declare body: string; + declare readAt: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare getSender: BelongsToGetAssociationMixin; + declare getRecipient: BelongsToGetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare getOrganization: BelongsToGetAssociationMixin; + + static associate(db: Db): void { + db.direct_messages.belongsTo(db.users, { + as: 'sender', + foreignKey: { name: 'senderId' }, + constraints: false, + }); + db.direct_messages.belongsTo(db.users, { + as: 'recipient', + foreignKey: { name: 'recipientId' }, + constraints: false, + }); + db.direct_messages.belongsTo(db.users, { + as: 'student', + foreignKey: { name: 'studentId' }, + constraints: false, + }); + db.direct_messages.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { name: 'organizationId' }, + constraints: false, + }); + } +} + +export default function (sequelize: Sequelize): typeof DirectMessages { + DirectMessages.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + senderId: { type: DataTypes.UUID, allowNull: false }, + recipientId: { type: DataTypes.UUID, allowNull: false }, + studentId: { type: DataTypes.UUID, allowNull: true }, + body: { type: DataTypes.TEXT, allowNull: false }, + readAt: { type: DataTypes.DATE, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + 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: 'direct_messages', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return DirectMessages; +} diff --git a/backend/src/db/models/frame_entries.ts b/backend/src/db/models/frame_entries.ts index 08fa029..818d755 100644 --- a/backend/src/db/models/frame_entries.ts +++ b/backend/src/db/models/frame_entries.ts @@ -35,6 +35,9 @@ export class FrameEntries extends Model< declare deletedAt: CreationOptional; declare organizationId: CreationOptional; declare campusId: CreationOptional; + /** Per-tenant content owner (one of org/school/campus/class is the leaf). */ + declare schoolId: CreationOptional; + declare classId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; @@ -129,6 +132,8 @@ export default function (sequelize: Sequelize): typeof FrameEntries { deletedAt: { type: DataTypes.DATE }, organizationId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true }, }, diff --git a/backend/src/db/models/guardian_students.ts b/backend/src/db/models/guardian_students.ts new file mode 100644 index 0000000..76b54d8 --- /dev/null +++ b/backend/src/db/models/guardian_students.ts @@ -0,0 +1,93 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { BelongsToGetAssociationMixin } from 'sequelize'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +/** + * Guardian ↔ student link (many-to-many). Students and guardians are **users** + * (roles), not SIS entities, so this junction relates two `users` rows: a + * student may have several guardians and a guardian several students. + * `relationship` is an optional label (parent / grandparent / guardian …). + * Tenant-scoped by `organizationId`; queries narrow further via the student's + * class/campus. + */ +export class GuardianStudents extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare guardianId: string; + declare studentId: string; + declare relationship: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare getGuardian: BelongsToGetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare getOrganization: BelongsToGetAssociationMixin; + + static associate(db: Db): void { + db.guardian_students.belongsTo(db.users, { + as: 'guardian', + foreignKey: { name: 'guardianId' }, + constraints: false, + }); + + db.guardian_students.belongsTo(db.users, { + as: 'student', + foreignKey: { name: 'studentId' }, + constraints: false, + }); + + db.guardian_students.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { name: 'organizationId' }, + constraints: false, + }); + + db.guardian_students.belongsTo(db.users, { as: 'createdBy' }); + db.guardian_students.belongsTo(db.users, { as: 'updatedBy' }); + } +} + +export default function (sequelize: Sequelize): typeof GuardianStudents { + GuardianStudents.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + guardianId: { type: DataTypes.UUID, allowNull: false }, + studentId: { type: DataTypes.UUID, allowNull: false }, + relationship: { type: DataTypes.TEXT, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + 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: 'guardian_students', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return GuardianStudents; +} diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index 2cd5b69..8b2d1b3 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -16,6 +16,7 @@ import attendance_sessions from './attendance_sessions'; import auth_refresh_tokens from './auth_refresh_tokens'; import campus_attendance_config from './campus_attendance_config'; import campus_attendance_summaries from './campus_attendance_summaries'; +import class_attendance from './class_attendance'; import campuses from './campuses'; import class_enrollments from './class_enrollments'; import class_subjects from './class_subjects'; @@ -25,6 +26,8 @@ import content_catalog from './content_catalog'; import file from './file'; import frame_entries from './frame_entries'; import grades from './grades'; +import guardian_students from './guardian_students'; +import direct_messages from './direct_messages'; import message_recipients from './message_recipients'; import messages from './messages'; import organizations from './organizations'; @@ -35,7 +38,6 @@ import policy_documents from './policy_documents'; import roles from './roles'; import safety_quiz_results from './safety_quiz_results'; import schools from './schools'; -import staff from './staff'; import staff_attendance_records from './staff_attendance_records'; import subjects from './subjects'; import timetable_periods from './timetable_periods'; @@ -112,6 +114,7 @@ const models = { auth_refresh_tokens: auth_refresh_tokens(sequelize), campus_attendance_config: campus_attendance_config(sequelize), campus_attendance_summaries: campus_attendance_summaries(sequelize), + class_attendance: class_attendance(sequelize), campuses: campuses(sequelize), class_enrollments: class_enrollments(sequelize), class_subjects: class_subjects(sequelize), @@ -121,6 +124,8 @@ const models = { file: file(sequelize), frame_entries: frame_entries(sequelize), grades: grades(sequelize), + guardian_students: guardian_students(sequelize), + direct_messages: direct_messages(sequelize), message_recipients: message_recipients(sequelize), messages: messages(sequelize), organizations: organizations(sequelize), @@ -131,7 +136,6 @@ const models = { roles: roles(sequelize), safety_quiz_results: safety_quiz_results(sequelize), schools: schools(sequelize), - staff: staff(sequelize), staff_attendance_records: staff_attendance_records(sequelize), subjects: subjects(sequelize), timetable_periods: timetable_periods(sequelize), diff --git a/backend/src/db/models/organizations.ts b/backend/src/db/models/organizations.ts index 749b437..ba78980 100644 --- a/backend/src/db/models/organizations.ts +++ b/backend/src/db/models/organizations.ts @@ -25,7 +25,6 @@ import type { Classes } from './classes'; import type { Grades } from './grades'; import type { MessageRecipients } from './message_recipients'; import type { Messages } from './messages'; -import type { Staff } from './staff'; import type { Subjects } from './subjects'; import type { TimetablePeriods } from './timetable_periods'; import type { Timetables } from './timetables'; @@ -37,6 +36,7 @@ export class Organizations extends Model< > { declare id: CreationOptional; declare name: string | null; + declare logo: CreationOptional; declare importHash: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; @@ -55,8 +55,6 @@ export class Organizations extends Model< declare setGrades_organization: HasManySetAssociationsMixin; declare getSubjects_organization: HasManyGetAssociationsMixin; declare setSubjects_organization: HasManySetAssociationsMixin; - declare getStaff_organization: HasManyGetAssociationsMixin; - declare setStaff_organization: HasManySetAssociationsMixin; declare getClasses_organization: HasManyGetAssociationsMixin; declare setClasses_organization: HasManySetAssociationsMixin; declare getClass_enrollments_organization: HasManyGetAssociationsMixin; @@ -148,15 +146,6 @@ export class Organizations extends Model< - db.organizations.hasMany(db.staff, { - as: 'staff_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.organizations.hasMany(db.classes, { as: 'classes_organization', foreignKey: { @@ -289,11 +278,13 @@ export default function (sequelize: Sequelize): typeof Organizations { name: { type: DataTypes.TEXT, - - + + }, + logo: { type: DataTypes.TEXT }, + importHash: { type: DataTypes.STRING(255), allowNull: true, diff --git a/backend/src/db/models/policy_documents.ts b/backend/src/db/models/policy_documents.ts index cb124d4..b02415a 100644 --- a/backend/src/db/models/policy_documents.ts +++ b/backend/src/db/models/policy_documents.ts @@ -38,6 +38,9 @@ export class PolicyDocuments extends Model< declare importHash: CreationOptional; declare organizationId: CreationOptional; declare campusId: CreationOptional; + /** Per-tenant content owner (one of org/school/campus/class is the leaf). */ + declare schoolId: CreationOptional; + declare classId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; declare createdAt: CreationOptional; @@ -96,6 +99,8 @@ export default function (sequelize: Sequelize): typeof PolicyDocuments { }, organizationId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true }, createdAt: { type: DataTypes.DATE }, diff --git a/backend/src/db/models/schools.ts b/backend/src/db/models/schools.ts index 2b604e5..6fb8ba6 100644 --- a/backend/src/db/models/schools.ts +++ b/backend/src/db/models/schools.ts @@ -34,6 +34,7 @@ export class Schools extends Model< declare phone: string | null; declare email: string | null; declare description: string | null; + declare logo: CreationOptional; declare active: CreationOptional; declare importHash: CreationOptional; declare organizationId: CreationOptional; @@ -84,6 +85,7 @@ export default function (sequelize: Sequelize): typeof Schools { phone: { type: DataTypes.TEXT }, email: { type: DataTypes.TEXT }, description: { type: DataTypes.TEXT }, + logo: { type: DataTypes.TEXT }, active: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/src/db/models/staff.ts b/backend/src/db/models/staff.ts deleted file mode 100644 index 6abfcbe..0000000 --- a/backend/src/db/models/staff.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type NonAttribute, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, -} from 'sequelize'; -import type { AttendanceSessions } from './attendance_sessions'; -import type { Campuses } from './campuses'; -import type { ClassSubjects } from './class_subjects'; -import type { Classes } from './classes'; -import type { File } from './file'; -import type { Organizations } from './organizations'; -import type { Schools } from './schools'; -import type { Users } from './users'; - -export class Staff extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare employee_number: string | null; - declare job_title: string | null; - declare staff_type: string | null; - declare hire_date: Date | null; - declare status: string | null; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare campusId: CreationOptional; - declare organizationId: CreationOptional; - /** School scope (Organization → School → Campus). Nullable. */ - declare schoolId: CreationOptional; - declare userId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getClasses_homeroom_teacher: HasManyGetAssociationsMixin; - declare setClasses_homeroom_teacher: HasManySetAssociationsMixin; - declare getClass_subjects_teacher: HasManyGetAssociationsMixin; - declare setClass_subjects_teacher: HasManySetAssociationsMixin; - declare getAttendance_sessions_taken_by: HasManyGetAssociationsMixin; - declare setAttendance_sessions_taken_by: HasManySetAssociationsMixin; - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getCampus: BelongsToGetAssociationMixin; - declare setCampus: BelongsToSetAssociationMixin; - declare getSchool: BelongsToGetAssociationMixin; - declare setSchool: BelongsToSetAssociationMixin; - // Eager-loaded association (populated by `include`). - declare campus?: NonAttribute; - declare getUser: BelongsToGetAssociationMixin; - declare setUser: BelongsToSetAssociationMixin; - declare getPhoto: HasManyGetAssociationsMixin; - declare setPhoto: HasManySetAssociationsMixin; - declare getCreatedBy: BelongsToGetAssociationMixin; - declare setCreatedBy: BelongsToSetAssociationMixin; - declare getUpdatedBy: BelongsToGetAssociationMixin; - declare setUpdatedBy: BelongsToSetAssociationMixin; - - static associate(db: Db): void { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - db.staff.hasMany(db.classes, { - as: 'classes_homeroom_teacher', - foreignKey: { - name: 'homeroom_teacherId', - }, - constraints: false, - }); - - - - db.staff.hasMany(db.class_subjects, { - as: 'class_subjects_teacher', - foreignKey: { - name: 'teacherId', - }, - constraints: false, - }); - - - - - db.staff.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_taken_by', - foreignKey: { - name: 'taken_byId', - }, - constraints: false, - }); - - - - - - - - - - - - -//end loop - - - - db.staff.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.staff.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.staff.belongsTo(db.schools, { - as: 'school', - foreignKey: { - name: 'schoolId', - }, - constraints: false, - }); - - db.staff.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - - - db.staff.hasMany(db.file, { - as: 'photo', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.staff.getTableName(), - belongsToColumn: 'photo', - }, - }); - - - db.staff.belongsTo(db.users, { - as: 'createdBy', - }); - - db.staff.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof Staff { - Staff.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -employee_number: { - type: DataTypes.TEXT, - - - - }, - -job_title: { - type: DataTypes.TEXT, - - - - }, - -staff_type: { - type: DataTypes.ENUM, - - - - values: [ - -"teacher", - - -"admin", - - -"support" - - ], - - }, - -hire_date: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"active", - - -"on_leave", - - -"inactive" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - createdAt: { type: DataTypes.DATE }, - updatedAt: { type: DataTypes.DATE }, - deletedAt: { type: DataTypes.DATE }, - campusId: { type: DataTypes.UUID, allowNull: true }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - schoolId: { type: DataTypes.UUID, allowNull: true }, - userId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'staff', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return Staff; -} diff --git a/backend/src/db/models/users.ts b/backend/src/db/models/users.ts index deb1c23..63b723f 100644 --- a/backend/src/db/models/users.ts +++ b/backend/src/db/models/users.ts @@ -27,10 +27,10 @@ import type { Campuses } from './campuses'; import type { File } from './file'; import type { Messages } from './messages'; import type { Organizations } from './organizations'; +import type { Classes } from './classes'; import type { Permissions } from './permissions'; import type { Roles } from './roles'; import type { Schools } from './schools'; -import type { Staff } from './staff'; const providers = config.providers; @@ -52,12 +52,13 @@ export class Users extends Model< declare passwordResetToken: CreationOptional; declare passwordResetTokenExpiresAt: CreationOptional; declare provider: CreationOptional; - declare importHash: CreationOptional; declare organizationId: CreationOptional; /** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */ declare campusId: CreationOptional; /** School scope for school-bound roles (Principal/Registrar). Nullable. */ declare schoolId: CreationOptional; + /** Class scope for class-bound roles (Teacher/Support Staff). Nullable. */ + declare classId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; declare createdAt: CreationOptional; @@ -68,15 +69,16 @@ export class Users extends Model< declare app_role?: NonAttribute; declare organizations?: NonAttribute; declare campus?: NonAttribute; - declare staff_user?: NonAttribute; + declare school?: NonAttribute; + declare class?: NonAttribute; declare custom_permissions?: NonAttribute; + declare custom_permissions_filter?: NonAttribute; + declare avatar?: NonAttribute; declare getCustom_permissions: BelongsToManyGetAssociationsMixin; declare setCustom_permissions: BelongsToManySetAssociationsMixin; declare getCustom_permissions_filter: BelongsToManyGetAssociationsMixin; declare setCustom_permissions_filter: BelongsToManySetAssociationsMixin; - declare getStaff_user: HasManyGetAssociationsMixin; - declare setStaff_user: HasManySetAssociationsMixin; declare getMessages_sent_by: HasManyGetAssociationsMixin; declare setMessages_sent_by: HasManySetAssociationsMixin; declare getApp_role: BelongsToGetAssociationMixin; @@ -87,6 +89,8 @@ export class Users extends Model< declare setCampus: BelongsToSetAssociationMixin; declare getSchool: BelongsToGetAssociationMixin; declare setSchool: BelongsToSetAssociationMixin; + declare getClass: BelongsToGetAssociationMixin; + declare setClass: BelongsToSetAssociationMixin; declare getAvatar: HasManyGetAssociationsMixin; declare setAvatar: HasManySetAssociationsMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -113,14 +117,6 @@ export class Users extends Model< through: 'usersCustom_permissionsPermissions', }); - db.users.hasMany(db.staff, { - as: 'staff_user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - db.users.hasMany(db.messages, { as: 'messages_sent_by', foreignKey: { @@ -161,6 +157,14 @@ export class Users extends Model< constraints: false, }); + db.users.belongsTo(db.classes, { + as: 'class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + db.users.hasMany(db.file, { as: 'avatar', foreignKey: 'belongsToId', @@ -244,14 +248,10 @@ export default function (sequelize: Sequelize): typeof Users { provider: { type: DataTypes.TEXT, }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, organizationId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true }, schoolId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true }, createdAt: { type: DataTypes.DATE }, diff --git a/backend/src/db/models/walkthrough_checkins.ts b/backend/src/db/models/walkthrough_checkins.ts index 8891b18..c62f94d 100644 --- a/backend/src/db/models/walkthrough_checkins.ts +++ b/backend/src/db/models/walkthrough_checkins.ts @@ -46,6 +46,9 @@ export class WalkthroughCheckins extends Model< declare deletedAt: CreationOptional; declare organizationId: CreationOptional; declare campusId: CreationOptional; + /** Per-tenant owner (boss evaluates own-tenant staff): one leaf is set. */ + declare schoolId: CreationOptional; + declare classId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; @@ -184,6 +187,8 @@ export default function (sequelize: Sequelize): typeof WalkthroughCheckins { deletedAt: { type: DataTypes.DATE }, organizationId: { type: DataTypes.UUID, allowNull: false }, campusId: { type: DataTypes.UUID, allowNull: true }, + schoolId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: false }, updatedById: { type: DataTypes.UUID, allowNull: true }, }, diff --git a/backend/src/db/seeders/20200430130759-admin-user.ts b/backend/src/db/seeders/20200430130759-admin-user.ts index 2f6fa6d..c84b897 100644 --- a/backend/src/db/seeders/20200430130759-admin-user.ts +++ b/backend/src/db/seeders/20200430130759-admin-user.ts @@ -10,7 +10,7 @@ import { ROLE_NAMES } from '@/shared/constants/roles'; import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures'; import type { Users } from '@/db/models/users'; -/** Documented seed credentials - see CLAUDE.md for reference. */ +/** Documented seed credentials - see AGENTS.md for reference. */ const SEED_DEFAULTS: Record = { SEED_ADMIN_PASSWORD: 'flatlogicAdmin123!', SEED_USER_PASSWORD: 'flatlogicUser123!', @@ -42,11 +42,12 @@ export default { const createdAt = new Date(); const updatedAt = new Date(); - const rows: CreationAttributes[] = SEED_ALL_USERS.map((user) => ({ + const rows: CreationAttributes[] = SEED_ALL_USERS.map((user, index) => ({ id: user.id, name_prefix: user.namePrefix ?? null, firstName: user.firstName, lastName: user.lastName, + phoneNumber: user.phoneNumber ?? `+1-555-010-${String(index + 1).padStart(2, '0')}`, // The super admin uses the configured SEED_ADMIN_EMAIL for login. email: user.role === ROLE_NAMES.SUPER_ADMIN ? adminEmail : user.email, emailVerified: true, diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 2f0b33a..57e0582 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -19,35 +19,40 @@ import type { Roles } from '@/db/models/roles'; import type { Permissions } from '@/db/models/permissions'; /** - * Seeds the 11 first-class roles (Workstream 3 §3.1), the permission catalog, + * Seeds the first-class roles (Workstream 3 §3.1), the permission catalog, * the role→permission preset matrix (§3.2), and assigns roles to the seeded - * users. Pre-launch, no legacy: this fully replaces the old generated role set. + * users. Pre-launch, this fully replaces the old generated role set. * * Enforcement model: - * - `super_admin` / `system_admin` are `globalAccess` and bypass per-permission - * checks (`middlewares/check-permissions.ts`), so they need no permission rows. - * - `owner` / `superintendent` / `director` get every permission (full CRUD), - * constrained at runtime to their organization/campus by tenant scoping. + * - `super_admin` keeps the global-scope bypass for platform recovery/admin. + * - `system_admin` keeps global scope reach but is permission-driven like the + * tenant roles, so the role receives an explicit full permission matrix. + * - `owner` / `superintendent` / `principal` / `director` get full-scope + * baseline permissions, constrained at runtime by scope/tenant rules. * - `office_manager` / `teacher` / `support_staff` get read-only entity access. - * - `student` / `guardian` / `guest` get no entity CRUD permissions (their - * access is the external product pages, gated on the frontend / future - * product-feature permissions). + * - `student` / `guardian` / `guest` get no entity CRUD permissions; their + * access comes from explicit product-feature permissions. */ -const PERMISSION_ENTITIES = [ - 'users', 'roles', 'permissions', 'organizations', 'campuses', - 'academic_years', 'grades', 'subjects', 'staff', - 'classes', 'class_enrollments', 'class_subjects', 'timetables', +export const PERMISSION_ENTITIES = [ + 'users', 'roles', 'permissions', 'organizations', 'schools', 'campuses', + 'academic_years', 'grades', 'subjects', + 'classes', 'class_enrollments', 'class_subjects', 'guardian_students', 'timetables', 'timetable_periods', 'attendance_sessions', 'attendance_records', 'assessments', 'assessment_results', 'messages', 'message_recipients', 'policy_documents', ]; -const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE']; -const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH']; +export const CRUD_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE'] as const; +export const EXTRA_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH'] as const; +export const FULL_ACCESS_EXCLUDED_FOR_ALL = ['READ_PARENT_COMM'] as const; +export const FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF = [ + 'ACK_POLICY', + 'ZONE_CHECKIN', +] as const; /** Roles granted every permission (full CRUD within their tenant/scope). */ -const FULL_ACCESS_ROLES: readonly RoleName[] = [ +export const FULL_ACCESS_ROLES: readonly RoleName[] = [ ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, // School head: full access, constrained to the school by tenant scoping. @@ -56,7 +61,7 @@ const FULL_ACCESS_ROLES: readonly RoleName[] = [ ]; /** Roles granted read-only access to tenant resources. */ -const READ_ONLY_ROLES: readonly RoleName[] = [ +export const READ_ONLY_ROLES: readonly RoleName[] = [ // Registrar: the Principal's read-only/audit assistant (school-wide visibility). ROLE_NAMES.REGISTRAR, ROLE_NAMES.OFFICE_MANAGER, @@ -65,7 +70,7 @@ const READ_ONLY_ROLES: readonly RoleName[] = [ ]; /** External (non-staff) roles. */ -const EXTERNAL_ROLES: readonly RoleName[] = [ +export const EXTERNAL_ROLES: readonly RoleName[] = [ ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ]; @@ -76,24 +81,28 @@ const EXTERNAL_ROLES: readonly RoleName[] = [ /** * Per-role product-feature grants for the non-global, non-full-access roles. - * `office_manager` excludes instructional tools and parent comms but may fill + * `office_manager` excludes instructional tools but includes parent comms and may fill * attendance; `teacher` includes instructional tools + parent comms; both * `teacher` and `support_staff` take quizzes / leave read receipts; - * `student`/`guardian` get only the external pages. + * `student` gets external pages; `guardian` gets external pages plus parent comms. */ -const MODULE_PERMISSIONS_BY_ROLE: Partial> = { +export const MODULE_PERMISSIONS_BY_ROLE: Partial> = { // Registrar: read every product surface across the school for audit, but no // action permissions (no fill-attendance/quiz/ack/zone, no audio manage). [ROLE_NAMES.REGISTRAR]: [ ...MODULE_READ_ALL_STAFF, ...MODULE_READ_INSTRUCTIONAL, - ...MODULE_READ_PARENT_COMM, ...MODULE_READ_EXTERNAL, ...MODULE_READ_DIRECTOR, + 'READ_STAFF_ATTENDANCE_REPORTS', + 'READ_SAFETY_QUIZ_REPORTS', + 'READ_PERSONALITY_REPORTS', + 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', 'READ_AUDIO_FILES', ], [ROLE_NAMES.OFFICE_MANAGER]: [ ...MODULE_READ_ALL_STAFF, + ...MODULE_READ_PARENT_COMM, ...MODULE_READ_EXTERNAL, ...MODULE_ACTIONS, 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', @@ -103,6 +112,8 @@ const MODULE_PERMISSIONS_BY_ROLE: Partial> = ...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_PARENT_COMM, ...MODULE_READ_EXTERNAL, + // Teacher fills their class's attendance (rolls up to campus/school/org). + 'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', ], @@ -114,9 +125,52 @@ const MODULE_PERMISSIONS_BY_ROLE: Partial> = 'READ_AUDIO_FILES', ], [ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL], - [ROLE_NAMES.GUARDIAN]: [...MODULE_READ_EXTERNAL], + // Guardians communicate with school staff via Parent Communication / Messages. + [ROLE_NAMES.GUARDIAN]: [...MODULE_READ_EXTERNAL, ...MODULE_READ_PARENT_COMM], }; +export function buildEntityPermissionNames(): readonly string[] { + const names: string[] = []; + for (const entity of PERMISSION_ENTITIES) { + for (const verb of CRUD_VERBS) { + names.push(`${verb}_${entity.toUpperCase()}`); + } + } + return names; +} + +export function buildSeededPermissionNamesForRole(role: RoleName): readonly string[] { + const entityPermissionNames = buildEntityPermissionNames(); + const allPermissionNames = [ + ...entityPermissionNames, + ...EXTRA_PERMISSIONS, + ...MODULE_PERMISSIONS, + ]; + + if (role === ROLE_NAMES.SYSTEM_ADMIN) { + return allPermissionNames; + } + + if (FULL_ACCESS_ROLES.includes(role)) { + const excluded = new Set(FULL_ACCESS_EXCLUDED_FOR_ALL); + if (role !== ROLE_NAMES.DIRECTOR) { + for (const name of FULL_ACCESS_EXCLUDED_FOR_NON_CAMPUS_STAFF) { + excluded.add(name); + } + } + return allPermissionNames.filter((name) => !excluded.has(name)); + } + + if (READ_ONLY_ROLES.includes(role)) { + return [ + ...entityPermissionNames.filter((name) => name.startsWith('READ_')), + ...(MODULE_PERMISSIONS_BY_ROLE[role] ?? []), + ]; + } + + return MODULE_PERMISSIONS_BY_ROLE[role] ?? []; +} + export default { async up(queryInterface: QueryInterface) { const createdAt = new Date(); @@ -143,12 +197,7 @@ export default { await queryInterface.bulkInsert('roles', roleRows); // 2. Permissions (entity CRUD + extras + product module/page perms). - const entityPermissionNames: string[] = []; - for (const entity of PERMISSION_ENTITIES) { - for (const verb of CRUD_VERBS) { - entityPermissionNames.push(`${verb}_${entity.toUpperCase()}`); - } - } + const entityPermissionNames = [...buildEntityPermissionNames()]; const allPermissionNames = [ ...entityPermissionNames, @@ -188,22 +237,17 @@ export default { } }; - const entityReadPermissions = entityPermissionNames.filter((n) => - n.startsWith('READ_'), - ); - // Full-access roles get every permission (incl. director-only module pages). - for (const role of FULL_ACCESS_ROLES) grant(role, allPermissionNames); - // Read-only staff: read every entity + their per-role module/page perms. - for (const role of READ_ONLY_ROLES) { - grant(role, entityReadPermissions); - grant(role, MODULE_PERMISSIONS_BY_ROLE[role] ?? []); + for (const role of [ + ROLE_NAMES.SYSTEM_ADMIN, + ...FULL_ACCESS_ROLES, + ...READ_ONLY_ROLES, + ...EXTERNAL_ROLES, + ]) { + grant(role, buildSeededPermissionNamesForRole(role)); } - // External roles: only their external module pages. - for (const role of EXTERNAL_ROLES) { - grant(role, MODULE_PERMISSIONS_BY_ROLE[role] ?? []); - } - // Workstream 11: the office_manager also manages policy documents (the - // director already does via full access; teacher/support stay read-only). + // Workstream 11: the office_manager also manages policy documents; director + // receives the same writes through the seeded full-scope permission matrix, + // while teacher/support stay read-only. grant(ROLE_NAMES.OFFICE_MANAGER, [ 'CREATE_POLICY_DOCUMENTS', 'UPDATE_POLICY_DOCUMENTS', diff --git a/backend/src/db/seeders/20260608103000-content-catalog.ts b/backend/src/db/seeders/20260608103000-content-catalog.ts index 61e851e..134114f 100644 --- a/backend/src/db/seeders/20260608103000-content-catalog.ts +++ b/backend/src/db/seeders/20260608103000-content-catalog.ts @@ -5,8 +5,81 @@ import { type QueryInterface, } from 'sequelize'; import type { ContentCatalog } from '@/db/models/content_catalog'; +import { + SEED_ORGANIZATION_ID, + SEED_SCHOOL_ID, + SEED_SCHOOL_2_ID, + SEED_SCHOOL_CAMPUS_IDS, + SEED_ORGANIZATION_2_ID, + SEED_SECONDARY_SCHOOL_ID, + SEED_SECONDARY_CAMPUS_ID, +} from '@/shared/constants/seed-fixtures'; +import { + PER_TENANT_CONTENT_TYPES, + SCHOOL_SCOPED_CONTENT_TYPES, + ORG_SCOPED_CONTENT_TYPES, +} from '@/shared/constants/content-catalog'; import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-catalog-seed-payloads'; +/** + * Owning-tenant ids for a seeded content type, derived from its scope class so + * scoped users actually see the demo content: per-tenant types are seeded at + * org, school, and campus levels; school-scoped at schools; org-scoped at orgs; + * the rest (truly global) carry no tenant. + */ +function seedTenants(contentType: string): Array<{ + organizationId: string | null; + schoolId: string | null; + campusId: string | null; +}> { + const demoCampusIds = [ + ...SEED_SCHOOL_CAMPUS_IDS[SEED_SCHOOL_ID], + ...SEED_SCHOOL_CAMPUS_IDS[SEED_SCHOOL_2_ID], + ]; + + if (PER_TENANT_CONTENT_TYPES.has(contentType)) { + return [ + { organizationId: SEED_ORGANIZATION_ID, schoolId: null, campusId: null }, + { organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_ID, campusId: null }, + { organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_2_ID, campusId: null }, + ...demoCampusIds.map((campusId) => ({ + organizationId: SEED_ORGANIZATION_ID, + schoolId: null, + campusId, + })), + { organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: null }, + { organizationId: SEED_ORGANIZATION_2_ID, schoolId: SEED_SECONDARY_SCHOOL_ID, campusId: null }, + { organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID }, + ]; + } + if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { + return [ + { organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_ID, campusId: null }, + { organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_2_ID, campusId: null }, + { organizationId: SEED_ORGANIZATION_2_ID, schoolId: SEED_SECONDARY_SCHOOL_ID, campusId: null }, + ]; + } + if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) { + return [ + { organizationId: SEED_ORGANIZATION_ID, schoolId: null, campusId: null }, + { organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: null }, + ]; + } + return [{ organizationId: null, schoolId: null, campusId: null }]; +} + +function tenantImportHash( + contentType: string, + stamp: { organizationId: string | null; schoolId: string | null; campusId: string | null }, +): string { + const suffix = stamp.campusId + ?? stamp.schoolId + ?? stamp.organizationId + ?? 'global'; + + return `content-catalog-${contentType}-${suffix}`; +} + const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { content_type: 'classroom-strategies', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomStrategies }, { content_type: 'safety-qbs-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyQbsQuiz }, @@ -18,26 +91,17 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { content_type: 'dashboard-encouraging-quotes', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardEncouragingQuotes }, { content_type: 'dashboard-compliance-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardComplianceItems }, { content_type: 'dashboard-sign-of-week', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek }, - { content_type: 'parent-message-templates', payload: CONTENT_CATALOG_SEED_PAYLOADS.parentMessageTemplates }, { content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations }, { content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities }, { content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions }, { content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics }, { content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips }, { content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics }, - { content_type: 'personality-quiz-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizQuestions }, - { content_type: 'personality-types', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityTypes }, { content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent }, // `safety-protocols` is no longer served from the content catalog — safety // protocols are now seeded into `policy_documents` (see the policy-documents // seeder), which the Safety Protocols page reads. - { content_type: 'classroom-timer-backgrounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerBackgrounds }, - { content_type: 'classroom-timer-sounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerSounds }, - { content_type: 'classroom-timer-presets', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerPresets }, - { content_type: 'classroom-timer-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerTips }, - { content_type: 'personality-quiz-features', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizFeatures }, { content_type: 'emotional-intelligence-weekly-focus', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyFocus }, - { content_type: 'personality-workplace-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityWorkplaceContent }, ]); export default { @@ -52,17 +116,19 @@ export default { }); const rows: CreationAttributes[] = - CONTENT_CATALOG_SEED_ROWS.map((row) => ({ + CONTENT_CATALOG_SEED_ROWS.flatMap((row) => seedTenants(row.content_type).map((stamp) => ({ id: uuid(), content_type: row.content_type, // payload is JSONB; bulkInsert needs a serialized value (Postgres casts // the JSON text to jsonb on insert). payload: JSON.stringify(row.payload), active: true, - importHash: 'content-catalog-' + row.content_type, + importHash: tenantImportHash(row.content_type, stamp), + ...stamp, + classId: null, createdAt: now, updatedAt: now, - })); + }))); await queryInterface.bulkInsert('content_catalog', rows); }, diff --git a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts index fe8f7da..455debf 100644 --- a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts +++ b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts @@ -1,4 +1,3 @@ -import { v4 as uuid } from 'uuid'; import { Op, QueryTypes, @@ -10,7 +9,10 @@ import { SEED_ORGANIZATION_NAME, SEED_ORGANIZATION_2_ID, SEED_ORGANIZATION_2_NAME, - SEED_SECONDARY_OWNER, + SEED_SECONDARY_CAMPUS_ID, + SEED_SECONDARY_SCHOOL_ID, + SEED_SECONDARY_SCHOOL_NAME, + SEED_SECONDARY_USERS, SEED_CAMPUS_ID, SEED_SCHOOL_ID, SEED_SCHOOL_NAME, @@ -22,18 +24,30 @@ import { import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses'; import type { Organizations } from '@/db/models/organizations'; import type { Schools } from '@/db/models/schools'; -import type { Staff } from '@/db/models/staff'; +import type { Campuses } from '@/db/models/campuses'; /** - * RBAC fixture links (Workstream 4): one company that owns two schools and the - * seeded campuses (each campus assigned to one school), the per-role users tied - * to the org/school/campus, and staff profiles covering every campus staff role - * on the `tigers` campus. Runs after the user and role seeders. Idempotent and - * reversible. + * RBAC fixture links (Workstream 4): the primary company owns two schools and + * the product campuses; the secondary company owns one school/campus for + * cross-tenant isolation and manual QA. Both tenants have per-role users tied + * to org/school/campus. Runs after + * the user and role seeders. Idempotent and reversible. */ const campusIds = PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id); -const staffFixtures = SEED_FIXTURE_USERS.filter((user) => user.staffType); -const staffUserIds = staffFixtures.map((user) => user.id); +const tenantFixtures = [ + { + users: SEED_FIXTURE_USERS, + organizationId: SEED_ORGANIZATION_ID, + schoolId: SEED_SCHOOL_ID, + campusId: SEED_CAMPUS_ID, + }, + { + users: SEED_SECONDARY_USERS, + organizationId: SEED_ORGANIZATION_2_ID, + schoolId: SEED_SECONDARY_SCHOOL_ID, + campusId: SEED_SECONDARY_CAMPUS_ID, + }, +] as const; export default { up: async (queryInterface: QueryInterface) => { @@ -59,21 +73,11 @@ export default { await queryInterface.bulkInsert('organizations', orgsToInsert); } - // The second-tenant owner is tied to the second organization. - await queryInterface.sequelize.query( - `UPDATE "users" SET "organizationId" = :org, "campusId" = NULL WHERE "id" = :id`, - { - replacements: { - org: SEED_ORGANIZATION_2_ID, - id: SEED_SECONDARY_OWNER.id, - }, - }, - ); - - // 2. The two schools under the primary company (idempotent). + // 2. Schools under the companies (idempotent). const schoolSeeds: CreationAttributes[] = [ { id: SEED_SCHOOL_ID, name: SEED_SCHOOL_NAME, code: 'north', active: true, organizationId: SEED_ORGANIZATION_ID, createdAt, updatedAt }, { id: SEED_SCHOOL_2_ID, name: SEED_SCHOOL_2_NAME, code: 'south', active: true, organizationId: SEED_ORGANIZATION_ID, createdAt, updatedAt }, + { id: SEED_SECONDARY_SCHOOL_ID, name: SEED_SECONDARY_SCHOOL_NAME, code: 'rival-north', active: true, organizationId: SEED_ORGANIZATION_2_ID, createdAt, updatedAt }, ]; const existingSchools = await queryInterface.sequelize.query<{ id: string }>( `SELECT id FROM schools WHERE id IN (:ids)`, @@ -90,7 +94,9 @@ export default { await queryInterface.bulkInsert('schools', schoolsToInsert); } - // 3. The company owns the seeded campuses; each campus belongs to one school. + // 3. The primary company owns the product campuses; each belongs to one + // school. The secondary company owns a separate campus so its scoped users + // are not attached to primary-tenant campuses. await queryInterface.sequelize.query( `UPDATE "campuses" SET "organizationId" = :org WHERE "id" IN (:ids)`, { replacements: { org: SEED_ORGANIZATION_ID, ids: campusIds } }, @@ -101,64 +107,64 @@ export default { { replacements: { school: schoolId, ids: [...ids] } }, ); } - - // 4. Tie each fixture user to the org/school/campus per its scope. - for (const user of SEED_FIXTURE_USERS) { - await queryInterface.sequelize.query( - `UPDATE "users" SET "organizationId" = :org, "schoolId" = :school, "campusId" = :campus WHERE "id" = :id`, - { - replacements: { - org: user.organization ? SEED_ORGANIZATION_ID : null, - school: user.school ? SEED_SCHOOL_ID : null, - campus: user.campus ? SEED_CAMPUS_ID : null, - id: user.id, - }, - }, - ); - } - - // 5. Staff profiles for the campus staff roles (idempotent by userId). - const existingStaff = await queryInterface.sequelize.query<{ - userId: string; - }>(`SELECT "userId" FROM staff WHERE "userId" IN (:ids)`, { - replacements: { ids: staffUserIds }, - type: QueryTypes.SELECT, - }); - const staffed = new Set(existingStaff.map((row) => row.userId)); - - const staffRows: CreationAttributes[] = staffFixtures - .filter((user) => !staffed.has(user.id)) - .map((user) => ({ - id: uuid(), - job_title: user.firstName, - staff_type: user.staffType ?? null, - status: 'active', - organizationId: SEED_ORGANIZATION_ID, - schoolId: user.school ? SEED_SCHOOL_ID : null, - campusId: SEED_CAMPUS_ID, - userId: user.id, + const existingSecondaryCampus = await queryInterface.sequelize.query<{ id: string }>( + `SELECT id FROM campuses WHERE id = :id`, + { + replacements: { id: SEED_SECONDARY_CAMPUS_ID }, + type: QueryTypes.SELECT, + }, + ); + if (existingSecondaryCampus.length === 0) { + const secondaryCampus: CreationAttributes = { + id: SEED_SECONDARY_CAMPUS_ID, + name: 'Rival Tigers Campus', + code: 'rival-tigers', + timezone: 'America/Phoenix', + mascot: 'Rival Tigers', + color: 'bg-orange-500', + bgGradient: 'from-orange-500 to-amber-500', + borderColor: 'border-orange-500/30', + textColor: 'text-orange-400', + bgLight: 'bg-orange-500/10', + description: 'Cross-tenant isolation campus', + isOnline: false, + active: true, + importHash: 'product-campus-rival-tigers', + organizationId: SEED_ORGANIZATION_2_ID, + schoolId: SEED_SECONDARY_SCHOOL_ID, createdAt, updatedAt, - })); - - if (staffRows.length > 0) { - await queryInterface.bulkInsert('staff', staffRows); + }; + await queryInterface.bulkInsert('campuses', [secondaryCampus]); } + + // 4. Tie each fixture user to its tenant org/school/campus per scope. + for (const tenant of tenantFixtures) { + for (const user of tenant.users) { + await queryInterface.sequelize.query( + `UPDATE "users" SET "organizationId" = :org, "schoolId" = :school, "campusId" = :campus WHERE "id" = :id`, + { + replacements: { + org: user.organization ? tenant.organizationId : null, + school: user.school ? tenant.schoolId : null, + campus: user.campus ? tenant.campusId : null, + id: user.id, + }, + }, + ); + } + } + }, down: async (queryInterface: QueryInterface) => { - await queryInterface.bulkDelete( - 'staff', - { userId: { [Op.in]: staffUserIds } }, - {}, - ); await queryInterface.sequelize.query( `UPDATE "users" SET "organizationId" = NULL, "schoolId" = NULL, "campusId" = NULL WHERE "id" IN (:ids)`, { replacements: { ids: [ ...SEED_FIXTURE_USERS.map((user) => user.id), - SEED_SECONDARY_OWNER.id, + ...SEED_SECONDARY_USERS.map((user) => user.id), ], }, }, @@ -167,9 +173,14 @@ export default { `UPDATE "campuses" SET "organizationId" = NULL, "schoolId" = NULL WHERE "id" IN (:ids)`, { replacements: { ids: campusIds } }, ); + await queryInterface.bulkDelete( + 'campuses', + { id: SEED_SECONDARY_CAMPUS_ID }, + {}, + ); await queryInterface.bulkDelete( 'schools', - { id: { [Op.in]: [SEED_SCHOOL_ID, SEED_SCHOOL_2_ID] } }, + { id: { [Op.in]: [SEED_SCHOOL_ID, SEED_SCHOOL_2_ID, SEED_SECONDARY_SCHOOL_ID] } }, {}, ); await queryInterface.bulkDelete( diff --git a/backend/src/db/seeders/20260612050000-class-fixtures.ts b/backend/src/db/seeders/20260612050000-class-fixtures.ts new file mode 100644 index 0000000..d490e94 --- /dev/null +++ b/backend/src/db/seeders/20260612050000-class-fixtures.ts @@ -0,0 +1,171 @@ +import { v4 as uuid } from 'uuid'; +import { QueryTypes, type QueryInterface } from 'sequelize'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { + SEED_ORGANIZATION_ID, + SEED_CAMPUS_ID, + SEED_CLASS_ID, + SEED_CLASS_NAME, + SEED_FIXTURE_USERS, + SEED_ORGANIZATION_2_ID, + SEED_SECONDARY_CAMPUS_ID, + SEED_SECONDARY_CLASS_ID, + SEED_SECONDARY_CLASS_NAME, + SEED_SECONDARY_USERS, +} from '@/shared/constants/seed-fixtures'; + +/** + * Class-tier fixtures: one homeroom per seeded tenant, with teacher/ + * support_staff users assigned to it, the student enrolled + * (`class_enrollments`), and the guardian linked to that student + * (`guardian_students`). Runs after the rbac-fixtures seeder. Idempotent. + */ +function fixtureId( + users: typeof SEED_FIXTURE_USERS | typeof SEED_SECONDARY_USERS, + role: string, +): string | null { + return users.find((u) => u.role === role)?.id ?? null; +} + +const classFixtures = [ + { + organizationId: SEED_ORGANIZATION_ID, + campusId: SEED_CAMPUS_ID, + classId: SEED_CLASS_ID, + className: SEED_CLASS_NAME, + teacherId: fixtureId(SEED_FIXTURE_USERS, ROLE_NAMES.TEACHER), + supportId: fixtureId(SEED_FIXTURE_USERS, ROLE_NAMES.SUPPORT_STAFF), + studentId: fixtureId(SEED_FIXTURE_USERS, ROLE_NAMES.STUDENT), + guardianId: fixtureId(SEED_FIXTURE_USERS, ROLE_NAMES.GUARDIAN), + }, + { + organizationId: SEED_ORGANIZATION_2_ID, + campusId: SEED_SECONDARY_CAMPUS_ID, + classId: SEED_SECONDARY_CLASS_ID, + className: SEED_SECONDARY_CLASS_NAME, + teacherId: fixtureId(SEED_SECONDARY_USERS, ROLE_NAMES.TEACHER), + supportId: fixtureId(SEED_SECONDARY_USERS, ROLE_NAMES.SUPPORT_STAFF), + studentId: fixtureId(SEED_SECONDARY_USERS, ROLE_NAMES.STUDENT), + guardianId: fixtureId(SEED_SECONDARY_USERS, ROLE_NAMES.GUARDIAN), + }, +] as const; + +function classMemberUserIds( + fixture: (typeof classFixtures)[number], +): readonly string[] { + return [fixture.teacherId, fixture.supportId].filter( + (id): id is string => id != null, + ); +} + +export default { + up: async (queryInterface: QueryInterface) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + for (const fixture of classFixtures) { + // 1. The homeroom class (idempotent), with the teacher as homeroom teacher. + const existingClass = await queryInterface.sequelize.query<{ id: string }>( + `SELECT id FROM classes WHERE id = :id`, + { replacements: { id: fixture.classId }, type: QueryTypes.SELECT }, + ); + if (existingClass.length === 0) { + await queryInterface.bulkInsert('classes', [ + { + id: fixture.classId, + name: fixture.className, + campusId: fixture.campusId, + organizationId: fixture.organizationId, + homeroom_teacherId: fixture.teacherId ?? null, + createdAt, + updatedAt, + }, + ]); + } + + // 2. Assign teacher/support to the class. + const memberUserIds = classMemberUserIds(fixture); + if (memberUserIds.length > 0) { + await queryInterface.sequelize.query( + `UPDATE "users" SET "classId" = :cls WHERE "id" IN (:ids)`, + { replacements: { cls: fixture.classId, ids: memberUserIds } }, + ); + } + + // 3. Enroll the student in the class (idempotent). + if (fixture.studentId) { + const existingEnrollment = await queryInterface.sequelize.query<{ + id: string; + }>( + `SELECT id FROM class_enrollments WHERE "classId" = :cls AND "studentId" = :sid`, + { + replacements: { cls: fixture.classId, sid: fixture.studentId }, + type: QueryTypes.SELECT, + }, + ); + if (existingEnrollment.length === 0) { + await queryInterface.bulkInsert('class_enrollments', [ + { + id: uuid(), + classId: fixture.classId, + studentId: fixture.studentId, + organizationId: fixture.organizationId, + enrolled_on: createdAt, + createdAt, + updatedAt, + }, + ]); + } + } + + // 4. Link the guardian to the student (idempotent). + if (fixture.guardianId && fixture.studentId) { + const existingLink = await queryInterface.sequelize.query<{ id: string }>( + `SELECT id FROM guardian_students WHERE "guardianId" = :gid AND "studentId" = :sid`, + { + replacements: { gid: fixture.guardianId, sid: fixture.studentId }, + type: QueryTypes.SELECT, + }, + ); + if (existingLink.length === 0) { + await queryInterface.bulkInsert('guardian_students', [ + { + id: uuid(), + guardianId: fixture.guardianId, + studentId: fixture.studentId, + relationship: 'parent', + organizationId: fixture.organizationId, + createdAt, + updatedAt, + }, + ]); + } + } + } + }, + + down: async (queryInterface: QueryInterface) => { + for (const fixture of classFixtures) { + if (fixture.studentId) { + await queryInterface.sequelize.query( + `DELETE FROM guardian_students WHERE "studentId" = :sid`, + { replacements: { sid: fixture.studentId } }, + ); + await queryInterface.sequelize.query( + `DELETE FROM class_enrollments WHERE "classId" = :cls AND "studentId" = :sid`, + { replacements: { cls: fixture.classId, sid: fixture.studentId } }, + ); + } + const memberUserIds = classMemberUserIds(fixture); + if (memberUserIds.length > 0) { + await queryInterface.sequelize.query( + `UPDATE "users" SET "classId" = NULL WHERE "id" IN (:ids)`, + { replacements: { ids: memberUserIds } }, + ); + } + await queryInterface.sequelize.query(`DELETE FROM classes WHERE id = :id`, { + replacements: { id: fixture.classId }, + }); + } + }, +}; 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 d621e6b..9f818c0 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 @@ -442,38 +442,6 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ alt: 'Help sign', description: 'Flat hand on top of fist, lift both up together', }, - parentMessageTemplates: [ - { - id: '1', - template: 'Your child had a wonderful day today! They showed great progress in [specific area]. Keep encouraging them at home!', - category: 'progress', - }, - { - id: '2', - template: 'Just a reminder that [event name] is coming up on [date]. Please [specific action needed]. Let us know if you have questions!', - category: 'event', - }, - { - id: '3', - template: "Today we noticed [specific behavior]. We used [strategy] to help. At home, you might try [home suggestion]. We're working together!", - category: 'behavior', - }, - { - id: '4', - template: "We wanted to share that your child successfully [achievement] today! This is a big step and we're so proud of their effort.", - category: 'progress', - }, - { - id: '5', - template: 'This week we are focusing on [skill/sign]. You can practice at home by [specific activity]. Consistency helps so much!', - category: 'general', - }, - { - id: '6', - template: 'Your child is learning the sign for "[sign word]" this week. Here\'s how to practice: [description]. Any attempt counts - celebrate it!', - category: 'general', - }, -], communityOrganizations: [ { id: '1', @@ -875,338 +843,6 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ { label: 'Participation Rate', value: '78%', color: 'text-emerald-400', bar: 'bg-emerald-500', width: '78%' }, { label: 'Average EI Score', value: '72%', color: 'text-blue-400', bar: 'bg-blue-500', width: '72%' }, { label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', width: '65%' }, -], - personalityQuizQuestions: [ - // E vs I (3 questions) - { - id: 1, - dimension: 'EI', - question: 'During a staff meeting, you feel most energized when:', - optionA: { text: 'Brainstorming ideas out loud with the group and building on others\' suggestions', value: 'E' }, - optionB: { text: 'Listening carefully, then sharing a well-thought-out idea you\'ve been developing internally', value: 'I' }, - }, - { - id: 2, - dimension: 'EI', - question: 'After a long, intense day with students, you recharge by:', - optionA: { text: 'Chatting with colleagues, grabbing coffee together, or calling a friend', value: 'E' }, - optionB: { text: 'Having quiet time alone — reading, walking, or just decompressing in silence', value: 'I' }, - }, - { - id: 3, - dimension: 'EI', - question: 'When working on a new classroom strategy, you prefer to:', - optionA: { text: 'Talk it through with a colleague or team to get immediate feedback', value: 'E' }, - optionB: { text: 'Research and reflect on your own first before discussing with others', value: 'I' }, - }, - // S vs N (3 questions) - { - id: 4, - dimension: 'SN', - question: 'When planning a lesson or activity, you tend to focus on:', - optionA: { text: 'Concrete, step-by-step instructions and proven methods that have worked before', value: 'S' }, - optionB: { text: 'The big picture concept and creative ways to make it engaging and meaningful', value: 'N' }, - }, - { - id: 5, - dimension: 'SN', - question: 'When a new policy or procedure is introduced, you first want to know:', - optionA: { text: 'The specific details — what exactly changes, when, and how it affects your daily routine', value: 'S' }, - optionB: { text: 'The reasoning behind it — why the change matters and what the long-term vision is', value: 'N' }, - }, - { - id: 6, - dimension: 'SN', - question: 'When observing a student\'s behavior, you naturally notice:', - optionA: { text: 'Specific, observable details — what they said, did, and the exact sequence of events', value: 'S' }, - optionB: { text: 'Patterns and underlying themes — what might be driving the behavior emotionally or socially', value: 'N' }, - }, - // T vs F (3 questions) - { - id: 7, - dimension: 'TF', - question: 'When a colleague disagrees with your approach to a student situation, you:', - optionA: { text: 'Present logical evidence and data to support why your approach is effective', value: 'T' }, - optionB: { text: 'Consider their perspective empathetically and look for a solution that honors both viewpoints', value: 'F' }, - }, - { - id: 8, - dimension: 'TF', - question: 'When making a decision about a student\'s behavior plan, you prioritize:', - optionA: { text: 'Consistency, fairness, and what the data shows is most effective', value: 'T' }, - optionB: { text: 'The student\'s emotional needs and how the plan will affect their sense of belonging', value: 'F' }, - }, - { - id: 9, - dimension: 'TF', - question: 'When giving feedback to a colleague, you tend to:', - optionA: { text: 'Be direct and specific about what needs improvement, focusing on outcomes', value: 'T' }, - optionB: { text: 'Start with what\'s going well, then gently suggest areas for growth with encouragement', value: 'F' }, - }, - // J vs P (3 questions) - { - id: 10, - dimension: 'JP', - question: 'Your ideal classroom or workspace is:', - optionA: { text: 'Organized with clear systems, schedules posted, and materials in designated spots', value: 'J' }, - optionB: { text: 'Flexible and adaptable — you know where things are even if it looks a bit creative', value: 'P' }, - }, - { - id: 11, - dimension: 'JP', - question: 'When an unexpected schedule change happens during the school day, you:', - optionA: { text: 'Feel a bit stressed and quickly create a new plan to stay on track', value: 'J' }, - optionB: { text: 'Roll with it naturally and see it as an opportunity to be spontaneous', value: 'P' }, - }, - { - id: 12, - dimension: 'JP', - question: 'When preparing for the next school week, you typically:', - optionA: { text: 'Plan everything in advance — lessons, materials, and contingencies are all mapped out', value: 'J' }, - optionB: { text: 'Have a general idea but prefer to stay flexible and adjust based on how the week unfolds', value: 'P' }, - }, -], - personalityTypes: [ - { - code: 'INTJ', - name: 'The Architect', - nickname: 'Strategic Visionary', - description: 'INTJs are strategic, independent thinkers who see the big picture and create long-term plans. In education, they excel at designing systems, analyzing data, and finding innovative solutions to complex challenges. They bring a calm, analytical presence to their teams.', - strengths: ['Strategic planning', 'Systems thinking', 'Independent problem-solving', 'Long-range vision'], - workRelationships: 'INTJs value competence and intellectual depth in their colleagues. They prefer working with people who are self-sufficient, logical, and open to constructive critique. They thrive in partnerships where ideas are debated respectfully, and they appreciate colleagues who come prepared and follow through on commitments. They may need encouragement to share their ideas more openly, as they tend to process internally before speaking.', - workplaceLanguage: 'INTJs communicate with precision and directness. They prefer concise, well-organized conversations that get to the point quickly. Their language tends to be analytical — they use phrases like "Based on the data..." or "The most efficient approach would be..." They may come across as blunt, but their intent is clarity, not coldness. They appreciate written communication (emails, documents) where they can organize their thoughts carefully.', - idealWorkEnvironment: 'Quiet, structured environments with autonomy and minimal micromanagement', - communicationStyle: 'Direct, analytical, and solution-focused', - color: 'from-indigo-500 to-purple-600', - bgColor: 'bg-indigo-500/10', - borderColor: 'border-indigo-500/20', - icon: 'architect', - }, - { - code: 'INTP', - name: 'The Logician', - nickname: 'Analytical Innovator', - description: 'INTPs are curious, analytical minds who love exploring ideas and understanding how things work. In education, they bring creative problem-solving and a unique perspective to challenges. They question assumptions and find unconventional solutions that others might miss.', - strengths: ['Analytical thinking', 'Creative problem-solving', 'Objective analysis', 'Intellectual curiosity'], - workRelationships: 'INTPs value intellectual stimulation and autonomy in their work relationships. They connect best with colleagues who enjoy exploring ideas and don\'t take disagreements personally. They prefer working with people who are open-minded and can engage in thoughtful debate. They may struggle with highly emotional or politically charged workplace dynamics and need space to think independently.', - workplaceLanguage: 'INTPs communicate through ideas and possibilities. They often use phrases like "What if we tried..." or "Have you considered..." Their language is exploratory and sometimes abstract. They may start multiple trains of thought before landing on their main point. They value accuracy over diplomacy and may need to be reminded to acknowledge others\' contributions before diving into analysis.', - idealWorkEnvironment: 'Intellectually stimulating spaces with freedom to explore and experiment', - communicationStyle: 'Exploratory, idea-driven, and questioning', - color: 'from-cyan-500 to-blue-600', - bgColor: 'bg-cyan-500/10', - borderColor: 'border-cyan-500/20', - icon: 'logician', - }, - { - code: 'ENTJ', - name: 'The Commander', - nickname: 'Decisive Leader', - description: 'ENTJs are natural leaders who organize people and resources to achieve goals efficiently. In education, they excel at driving initiatives, setting high standards, and motivating teams to reach their potential. They bring energy, confidence, and a results-oriented mindset.', - strengths: ['Leadership', 'Strategic execution', 'Team motivation', 'Goal achievement'], - workRelationships: 'ENTJs value efficiency and competence in their colleagues. They prefer working with people who are proactive, reliable, and willing to take ownership of their responsibilities. They build strong professional relationships through shared goals and mutual respect. They appreciate directness and may become frustrated with indecisiveness or lack of follow-through. They need to remember to balance their drive with patience for different working styles.', - workplaceLanguage: 'ENTJs communicate with authority and clarity. They use decisive language — "Here\'s the plan," "We need to," "The priority is..." Their communication is goal-oriented and action-focused. They naturally take charge in conversations and meetings. They should be mindful of leaving space for others to contribute and softening their delivery when working with more sensitive colleagues.', - idealWorkEnvironment: 'Dynamic, goal-driven environments where they can lead and make an impact', - communicationStyle: 'Commanding, clear, and action-oriented', - color: 'from-red-500 to-orange-600', - bgColor: 'bg-red-500/10', - borderColor: 'border-red-500/20', - icon: 'commander', - }, - { - code: 'ENTP', - name: 'The Debater', - nickname: 'Creative Challenger', - description: 'ENTPs are energetic innovators who love challenging the status quo and exploring new possibilities. In education, they bring fresh perspectives, creative solutions, and an infectious enthusiasm for trying new approaches. They keep teams thinking and growing.', - strengths: ['Innovation', 'Adaptability', 'Persuasion', 'Creative thinking'], - workRelationships: 'ENTPs thrive in relationships with colleagues who enjoy intellectual sparring and aren\'t afraid of new ideas. They connect through humor, debate, and shared enthusiasm for innovation. They value colleagues who can keep up with their rapid-fire ideas and help them refine their best ones. They may need to be more sensitive to colleagues who prefer stability and routine.', - workplaceLanguage: 'ENTPs communicate with enthusiasm and wit. They use phrases like "Why don\'t we try..." or "What if we flipped this..." Their language is persuasive and often playful. They enjoy devil\'s advocate positions and may challenge ideas not because they disagree, but because they want to strengthen the thinking. They should be aware that their debating style can feel confrontational to some colleagues.', - idealWorkEnvironment: 'Fast-paced, flexible environments that welcome experimentation and debate', - communicationStyle: 'Enthusiastic, persuasive, and intellectually challenging', - color: 'from-amber-500 to-yellow-600', - bgColor: 'bg-amber-500/10', - borderColor: 'border-amber-500/20', - icon: 'debater', - }, - { - code: 'INFJ', - name: 'The Advocate', - nickname: 'Insightful Guide', - description: 'INFJs are deeply empathetic visionaries who are driven by their values and desire to help others grow. In education, they excel at understanding students\' deeper needs, creating meaningful connections, and inspiring positive change. They bring wisdom, compassion, and quiet determination.', - strengths: ['Deep empathy', 'Visionary thinking', 'Meaningful connections', 'Values-driven leadership'], - workRelationships: 'INFJs seek authentic, meaningful connections with their colleagues. They value trust, mutual respect, and shared purpose. They prefer working with people who are genuine, compassionate, and committed to making a difference. They are excellent listeners and often become the person colleagues confide in. They may need to set boundaries to avoid emotional exhaustion and should seek out colleagues who reciprocate their depth of care.', - workplaceLanguage: 'INFJs communicate with warmth, depth, and purpose. They use phrases like "I feel like this matters because..." or "What I\'m sensing is..." Their language is values-driven and often metaphorical. They speak with conviction about things they believe in and can be surprisingly persuasive. They prefer one-on-one conversations over large group discussions and may need encouragement to share their insights in team settings.', - idealWorkEnvironment: 'Purposeful, harmonious environments where their work has meaningful impact', - communicationStyle: 'Warm, insightful, and values-driven', - color: 'from-emerald-500 to-teal-600', - bgColor: 'bg-emerald-500/10', - borderColor: 'border-emerald-500/20', - icon: 'advocate', - }, - { - code: 'INFP', - name: 'The Mediator', - nickname: 'Compassionate Idealist', - description: 'INFPs are creative, empathetic individuals guided by strong inner values. In education, they bring authenticity, creativity, and a deep understanding of each student\'s unique needs. They create safe, nurturing environments where students feel truly seen and valued.', - strengths: ['Creativity', 'Authentic empathy', 'Individual attention', 'Values alignment'], - workRelationships: 'INFPs value authenticity and kindness in their work relationships. They connect deeply with colleagues who share their values and passion for helping students. They prefer collaborative environments over competitive ones and thrive when they feel their unique contributions are appreciated. They may avoid conflict, so they benefit from colleagues who create safe spaces for honest dialogue.', - workplaceLanguage: 'INFPs communicate with sincerity and creativity. They use phrases like "I really believe..." or "What matters most here is..." Their language is personal and heartfelt. They may express ideas through stories, analogies, or creative examples rather than bullet points. They are excellent written communicators and may prefer email or notes over spontaneous verbal exchanges when discussing important topics.', - idealWorkEnvironment: 'Creative, supportive environments that honor individuality and personal growth', - communicationStyle: 'Sincere, creative, and personally meaningful', - color: 'from-pink-500 to-rose-600', - bgColor: 'bg-pink-500/10', - borderColor: 'border-pink-500/20', - icon: 'mediator', - }, - { - code: 'ENFJ', - name: 'The Protagonist', - nickname: 'Inspiring Mentor', - description: 'ENFJs are charismatic, empathetic leaders who inspire others to reach their potential. In education, they naturally build strong teams, create positive cultures, and advocate passionately for their students and colleagues. They bring warmth, vision, and infectious optimism.', - strengths: ['Inspirational leadership', 'Team building', 'Empathetic communication', 'Cultural development'], - workRelationships: 'ENFJs are natural relationship builders who invest deeply in their colleagues\' growth and wellbeing. They create inclusive, supportive team dynamics and often serve as the emotional glue that holds teams together. They value harmony and work hard to resolve conflicts. They connect best with colleagues who are genuine, growth-oriented, and willing to contribute to a positive team culture. They should be mindful of not overextending themselves for others.', - workplaceLanguage: 'ENFJs communicate with warmth, enthusiasm, and encouragement. They use phrases like "I believe in you," "We can do this together," and "Let me help you with that." Their language is inclusive and motivating. They naturally affirm others and create psychological safety in conversations. They should be aware that their desire for harmony may sometimes prevent them from addressing difficult issues directly.', - idealWorkEnvironment: 'Collaborative, people-centered environments where they can mentor and inspire', - communicationStyle: 'Warm, encouraging, and inclusive', - color: 'from-orange-500 to-amber-600', - bgColor: 'bg-orange-500/10', - borderColor: 'border-orange-500/20', - icon: 'protagonist', - }, - { - code: 'ENFP', - name: 'The Campaigner', - nickname: 'Enthusiastic Connector', - description: 'ENFPs are creative, enthusiastic individuals who see potential everywhere and in everyone. In education, they bring energy, innovation, and an ability to connect with students on a personal level. They make learning exciting and help others discover their passions.', - strengths: ['Enthusiasm', 'Creative connections', 'Adaptability', 'Inspiring others'], - workRelationships: 'ENFPs build warm, energetic relationships with their colleagues. They are natural connectors who bring people together and create a fun, positive atmosphere. They value authenticity and freedom in their work relationships and connect best with colleagues who are open-minded, creative, and supportive. They may struggle with overly rigid or critical colleagues and need encouragement to follow through on their many ideas.', - workplaceLanguage: 'ENFPs communicate with energy, creativity, and personal warmth. They use phrases like "I have an amazing idea!" or "Imagine if we could..." Their language is expressive, enthusiastic, and often peppered with personal anecdotes. They think out loud and may jump between topics as connections spark in their mind. They should practice focusing their communication and following up on commitments they make in the excitement of the moment.', - idealWorkEnvironment: 'Flexible, creative environments that celebrate individuality and new ideas', - communicationStyle: 'Energetic, expressive, and personally engaging', - color: 'from-yellow-500 to-orange-500', - bgColor: 'bg-yellow-500/10', - borderColor: 'border-yellow-500/20', - icon: 'campaigner', - }, - { - code: 'ISTJ', - name: 'The Logistician', - nickname: 'Reliable Organizer', - description: 'ISTJs are dependable, thorough professionals who value tradition, responsibility, and doing things right. In education, they bring structure, consistency, and meticulous attention to detail. They are the backbone of any well-run classroom or school.', - strengths: ['Reliability', 'Attention to detail', 'Organizational skills', 'Consistency'], - workRelationships: 'ISTJs value reliability and professionalism in their colleagues. They build trust through consistent actions rather than words and prefer working with people who follow through on their commitments. They are loyal team members who can always be counted on. They connect best with colleagues who respect established procedures and communicate clearly. They may need to be more flexible with colleagues who have different working styles.', - workplaceLanguage: 'ISTJs communicate with clarity, precision, and factual accuracy. They use phrases like "According to the procedure..." or "The data shows..." Their language is straightforward and practical. They prefer clear, organized communication and may become frustrated with vague or overly emotional discussions. They are excellent at documenting processes and creating clear written instructions.', - idealWorkEnvironment: 'Structured, organized environments with clear expectations and procedures', - communicationStyle: 'Clear, factual, and procedure-oriented', - color: 'from-slate-500 to-gray-600', - bgColor: 'bg-slate-500/10', - borderColor: 'border-slate-500/20', - icon: 'logistician', - }, - { - code: 'ISFJ', - name: 'The Defender', - nickname: 'Nurturing Protector', - description: 'ISFJs are warm, dedicated individuals who quietly ensure everything runs smoothly and everyone is cared for. In education, they create stable, nurturing environments and go above and beyond for their students and colleagues. They are the unsung heroes of every school.', - strengths: ['Dedication', 'Nurturing care', 'Practical support', 'Attention to individual needs'], - workRelationships: 'ISFJs are loyal, supportive colleagues who remember the little things — birthdays, preferences, and personal challenges. They build relationships through acts of service and consistent care. They value harmony and work hard to maintain positive team dynamics. They connect best with colleagues who appreciate their contributions and reciprocate their thoughtfulness. They may need to practice saying no and setting boundaries to avoid burnout.', - workplaceLanguage: 'ISFJs communicate with warmth, practicality, and consideration. They use phrases like "How can I help?" or "I noticed you might need..." Their language is supportive and detail-oriented. They prefer gentle, respectful communication and may be hurt by blunt or critical feedback. They express care through actions more than words and may need encouragement to voice their own needs and opinions in team settings.', - idealWorkEnvironment: 'Stable, appreciative environments where their contributions are recognized', - communicationStyle: 'Warm, supportive, and detail-conscious', - color: 'from-teal-500 to-emerald-600', - bgColor: 'bg-teal-500/10', - borderColor: 'border-teal-500/20', - icon: 'defender', - }, - { - code: 'ESTJ', - name: 'The Executive', - nickname: 'Efficient Organizer', - description: 'ESTJs are organized, decisive leaders who value order, tradition, and getting things done. In education, they excel at managing operations, enforcing standards, and creating efficient systems. They bring structure and accountability to every team they join.', - strengths: ['Organization', 'Decisive action', 'Standards enforcement', 'Operational efficiency'], - workRelationships: 'ESTJs value competence, punctuality, and professionalism in their colleagues. They build respect through hard work and expect the same from others. They are natural organizers who take charge of projects and ensure deadlines are met. They connect best with colleagues who are responsible, direct, and committed to excellence. They should practice patience with colleagues who work at a different pace or have a more flexible approach.', - workplaceLanguage: 'ESTJs communicate with authority, clarity, and directness. They use phrases like "Here\'s what needs to happen," "The deadline is," and "Let\'s stay focused." Their language is task-oriented and efficient. They value clear agendas, action items, and follow-up. They may come across as bossy or inflexible, so they should practice acknowledging others\' input and being open to alternative approaches.', - idealWorkEnvironment: 'Well-organized environments with clear hierarchies and measurable goals', - communicationStyle: 'Direct, organized, and results-driven', - color: 'from-blue-600 to-indigo-700', - bgColor: 'bg-blue-600/10', - borderColor: 'border-blue-600/20', - icon: 'executive', - }, - { - code: 'ESFJ', - name: 'The Consul', - nickname: 'Caring Coordinator', - description: 'ESFJs are warm, social individuals who create harmony and ensure everyone feels included. In education, they excel at building community, coordinating events, and maintaining positive relationships with students, families, and colleagues. They are the heart of school culture.', - strengths: ['Community building', 'Social coordination', 'Inclusive leadership', 'Relationship maintenance'], - workRelationships: 'ESFJs are the social connectors of any team. They remember everyone\'s names, organize team celebrations, and make sure no one feels left out. They build relationships through genuine care and consistent follow-through. They value loyalty, cooperation, and positive team spirit. They connect best with colleagues who are appreciative, collaborative, and socially engaged. They may take criticism personally and need reassurance that they are valued.', - workplaceLanguage: 'ESFJs communicate with warmth, enthusiasm, and social awareness. They use phrases like "How is everyone feeling about this?" or "Let\'s make sure we include..." Their language is inclusive, encouraging, and relationship-focused. They are excellent at reading the room and adjusting their communication style to match their audience. They should practice being comfortable with constructive disagreement and not interpreting it as personal rejection.', - idealWorkEnvironment: 'Social, cooperative environments with strong team bonds and shared traditions', - communicationStyle: 'Warm, inclusive, and socially attuned', - color: 'from-rose-500 to-pink-600', - bgColor: 'bg-rose-500/10', - borderColor: 'border-rose-500/20', - icon: 'consul', - }, - { - code: 'ISTP', - name: 'The Virtuoso', - nickname: 'Practical Problem-Solver', - description: 'ISTPs are hands-on, adaptable individuals who excel at troubleshooting and finding practical solutions. In education, they bring a calm, resourceful presence and an ability to handle unexpected situations with ease. They are the go-to person when something needs fixing — literally or figuratively.', - strengths: ['Practical problem-solving', 'Crisis management', 'Hands-on skills', 'Calm under pressure'], - workRelationships: 'ISTPs value independence and mutual respect in their work relationships. They prefer colleagues who are competent, low-drama, and action-oriented. They build trust through demonstrated skill rather than social niceties. They are reliable in a crisis and appreciate colleagues who don\'t panic. They may seem reserved or detached, but they show care through practical actions — fixing things, solving problems, and stepping up when it matters.', - workplaceLanguage: 'ISTPs communicate with brevity and practicality. They use phrases like "Let me take a look at that," "Here\'s what I\'d do," or simply demonstrate solutions through action. Their language is minimal and to-the-point. They prefer showing over telling and may become impatient with lengthy discussions that don\'t lead to action. They should practice sharing their reasoning more openly so colleagues understand their thought process.', - idealWorkEnvironment: 'Hands-on environments with variety, autonomy, and real problems to solve', - communicationStyle: 'Brief, practical, and action-oriented', - color: 'from-zinc-500 to-stone-600', - bgColor: 'bg-zinc-500/10', - borderColor: 'border-zinc-500/20', - icon: 'virtuoso', - }, - { - code: 'ISFP', - name: 'The Adventurer', - nickname: 'Gentle Creative', - description: 'ISFPs are gentle, creative individuals who bring beauty and authenticity to everything they do. In education, they connect with students through creative expression, patience, and a genuine acceptance of each person\'s uniqueness. They create calm, aesthetically pleasing environments that promote learning.', - strengths: ['Creative expression', 'Gentle patience', 'Authentic connections', 'Aesthetic awareness'], - workRelationships: 'ISFPs value authenticity, kindness, and creative freedom in their work relationships. They connect best with colleagues who are genuine, non-judgmental, and respectful of personal space. They show care through thoughtful gestures and creative contributions rather than verbal expressions. They may avoid confrontation and need colleagues who create safe spaces for honest communication. They thrive when their unique creative contributions are noticed and appreciated.', - workplaceLanguage: 'ISFPs communicate with gentleness and sincerity. They use phrases like "I feel like..." or "What if we tried something different..." Their language is personal, understated, and often expressed through creative means — visual displays, handwritten notes, or carefully chosen words. They prefer one-on-one conversations and may become quiet in large group settings. They should be encouraged to share their creative ideas more openly.', - idealWorkEnvironment: 'Calm, aesthetically pleasing environments with creative freedom and personal space', - communicationStyle: 'Gentle, sincere, and creatively expressive', - color: 'from-violet-500 to-purple-600', - bgColor: 'bg-violet-500/10', - borderColor: 'border-violet-500/20', - icon: 'adventurer', - }, - { - code: 'ESTP', - name: 'The Entrepreneur', - nickname: 'Dynamic Doer', - description: 'ESTPs are energetic, action-oriented individuals who thrive in the moment. In education, they bring excitement, adaptability, and a hands-on approach that engages even the most reluctant learners. They are excellent at reading situations and responding quickly.', - strengths: ['Quick action', 'Situational awareness', 'Engaging energy', 'Practical adaptability'], - workRelationships: 'ESTPs build relationships through shared experiences and humor. They value colleagues who are fun, competent, and ready to jump into action. They prefer working with people who don\'t overthink things and can keep up with their fast pace. They are generous with their time and energy when they see a need. They may struggle with colleagues who are overly cautious or process-heavy and should practice patience with different working styles.', - workplaceLanguage: 'ESTPs communicate with energy, humor, and directness. They use phrases like "Let\'s just do it," "Watch this," or "I\'ve got an idea — follow me." Their language is action-oriented and often accompanied by physical demonstration. They are natural storytellers who use humor to connect and persuade. They should practice slowing down to listen fully before jumping to solutions and being more sensitive in their delivery of feedback.', - idealWorkEnvironment: 'Active, dynamic environments with variety and opportunities for hands-on engagement', - communicationStyle: 'Energetic, direct, and action-driven', - color: 'from-orange-600 to-red-600', - bgColor: 'bg-orange-600/10', - borderColor: 'border-orange-600/20', - icon: 'entrepreneur', - }, - { - code: 'ESFP', - name: 'The Entertainer', - nickname: 'Joyful Energizer', - description: 'ESFPs are vibrant, spontaneous individuals who bring joy and energy to every room they enter. In education, they create fun, engaging learning experiences and have a natural ability to connect with students through warmth, humor, and genuine enthusiasm.', - strengths: ['Joyful energy', 'Student engagement', 'Spontaneous creativity', 'Warm connections'], - workRelationships: 'ESFPs are the life of the staff room. They build relationships through shared laughter, spontaneous adventures, and genuine warmth. They value colleagues who are positive, fun-loving, and supportive. They create an atmosphere where people feel comfortable being themselves. They connect best with colleagues who appreciate their energy and don\'t try to dim their light. They may need help staying focused on long-term goals and following through on administrative tasks.', - workplaceLanguage: 'ESFPs communicate with enthusiasm, warmth, and expressiveness. They use phrases like "This is going to be so fun!" or "I love that idea!" Their language is animated, personal, and often accompanied by expressive gestures and facial expressions. They are natural entertainers who use humor and storytelling to engage their audience. They should practice being more concise in professional settings and balancing fun with focus during important discussions.', - idealWorkEnvironment: 'Fun, social environments with variety, teamwork, and opportunities to perform', - communicationStyle: 'Enthusiastic, expressive, and warmly engaging', - color: 'from-fuchsia-500 to-pink-600', - bgColor: 'bg-fuchsia-500/10', - borderColor: 'border-fuchsia-500/20', - icon: 'entertainer', - }, ], esaFundingContent: { approvedUses: [ @@ -1322,179 +958,40 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ 'Communicate clearly and simply', ], }, -], - classroomTimerBackgrounds: [ - { - id: 'ocean', - name: 'Ocean Calm', - iconId: 'waves', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662475079_dabe421c.png', - overlay: 'from-blue-950/40 via-blue-900/20 to-cyan-950/40', - textColor: 'text-cyan-100', - accentColor: 'text-cyan-300', - ringColor: 'stroke-cyan-400', - trackColor: 'stroke-cyan-900/50', - }, - { - id: 'aurora', - name: 'Aurora Borealis', - iconId: 'sparkles', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662490787_3aabfbd4.jpg', - overlay: 'from-purple-950/40 via-emerald-950/20 to-indigo-950/40', - textColor: 'text-emerald-100', - accentColor: 'text-emerald-300', - ringColor: 'stroke-emerald-400', - trackColor: 'stroke-emerald-900/50', - }, - { - id: 'lava', - name: 'Lava Lamp', - iconId: 'sun', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662521371_be5368c4.png', - overlay: 'from-orange-950/40 via-red-950/20 to-purple-950/40', - textColor: 'text-orange-100', - accentColor: 'text-orange-300', - ringColor: 'stroke-orange-400', - trackColor: 'stroke-orange-900/50', - }, - { - id: 'galaxy', - name: 'Deep Galaxy', - iconId: 'moon', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662574948_03baf94b.png', - overlay: 'from-indigo-950/40 via-purple-950/20 to-blue-950/40', - textColor: 'text-purple-100', - accentColor: 'text-purple-300', - ringColor: 'stroke-purple-400', - trackColor: 'stroke-purple-900/50', - }, - { - id: 'forest', - name: 'Enchanted Forest', - iconId: 'tree-pine', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662588347_123efdd7.jpg', - overlay: 'from-green-950/40 via-emerald-950/20 to-green-950/40', - textColor: 'text-green-100', - accentColor: 'text-green-300', - ringColor: 'stroke-green-400', - trackColor: 'stroke-green-900/50', - }, - { - id: 'rain', - name: 'Rainy Day', - iconId: 'cloud-rain', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662620449_93a5fbcd.png', - overlay: 'from-slate-950/40 via-blue-950/20 to-slate-950/40', - textColor: 'text-blue-100', - accentColor: 'text-blue-300', - ringColor: 'stroke-blue-400', - trackColor: 'stroke-blue-900/50', - }, - { - id: 'coral', - name: 'Coral Reef', - iconId: 'fish', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662661000_e74a933f.png', - overlay: 'from-teal-950/40 via-cyan-950/20 to-teal-950/40', - textColor: 'text-teal-100', - accentColor: 'text-teal-300', - ringColor: 'stroke-teal-400', - trackColor: 'stroke-teal-900/50', - }, - { - id: 'sunset', - name: 'Sunset Sky', - iconId: 'mountain', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662744948_e27daa4c.png', - overlay: 'from-rose-950/40 via-amber-950/20 to-purple-950/40', - textColor: 'text-rose-100', - accentColor: 'text-rose-300', - ringColor: 'stroke-rose-400', - trackColor: 'stroke-rose-900/50', - }, -], - classroomTimerSounds: [ - { id: 'gentle-chime', name: 'Gentle Chime', icon: '🔔', frequency: 523.25 }, - { id: 'soft-bell', name: 'Soft Bell', icon: '🛎', frequency: 440 }, - { id: 'xylophone', name: 'Xylophone', icon: '🎵', frequency: 659.25 }, - { id: 'singing-bowl', name: 'Singing Bowl', icon: '🎶', frequency: 256 }, - { id: 'nature-birds', name: 'Nature Birds', icon: '🐦', frequency: 880 }, - { id: 'ocean-wave', name: 'Ocean Wave', icon: '🌊', frequency: 200 }, - { id: 'rain-stick', name: 'Rain Stick', icon: '🌧', frequency: 300 }, - { id: 'harp-gliss', name: 'Harp Glissando', icon: '🎵', frequency: 392 }, -], - classroomTimerPresets: [ - { label: '30s', seconds: 30 }, - { label: '1 min', seconds: 60 }, - { label: '2 min', seconds: 120 }, - { label: '3 min', seconds: 180 }, - { label: '5 min', seconds: 300 }, - { label: '10 min', seconds: 600 }, - { label: '15 min', seconds: 900 }, - { label: '20 min', seconds: 1200 }, - { label: '25 min', seconds: 1500 }, - { label: '30 min', seconds: 1800 }, -], - classroomTimerTips: [ - { - title: 'Transitions', - body: 'Use 5-3-1 minute warnings before activity changes. The visual countdown reduces anxiety about unexpected transitions.', - }, - { - title: 'Sensory Backgrounds', - body: 'Project calming backgrounds during work time or sensory breaks. Ocean and forest themes are great for de-escalation.', - }, - { - title: 'Sound Choices', - body: 'Choose gentle sounds - avoid startling tones. Singing bowl and gentle chime work well for students sensitive to sudden sounds.', - }, -], - personalityQuizFeatures: [ - { - id: 'questions', - label: '12 Questions', - description: 'Thoughtfully designed for educators', - toneClass: 'text-violet-400 bg-violet-500/10 border-violet-500/20', - }, - { - id: 'relationships', - label: 'Work Relationships', - description: 'How you connect with colleagues', - toneClass: 'text-blue-400 bg-blue-500/10 border-blue-500/20', - }, - { - id: 'language', - label: 'Workplace Language', - description: 'Your communication patterns', - toneClass: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', - }, - { - id: 'growth', - label: 'Professional Growth', - description: 'Leverage your natural strengths', - toneClass: 'text-amber-400 bg-amber-500/10 border-amber-500/20', - }, ], emotionalIntelligenceWeeklyFocus: { title: 'Stress Regulation', description: 'Emotional regulation during escalations - pause, breathe, respond instead of react. Notice your own zone before intervening with a student.', -}, - personalityWorkplaceContent: { - mbtiDescription: 'The Myers-Briggs Type Indicator identifies personality preferences across four dimensions, creating 16 unique personality types. Each type has distinct strengths in the workplace.', - dimensions: [ - { dim: 'E / I', label: 'Extraversion vs. Introversion', desc: 'Where you get your energy' }, - { dim: 'S / N', label: 'Sensing vs. Intuition', desc: 'How you gather information' }, - { dim: 'T / F', label: 'Thinking vs. Feeling', desc: 'How you make decisions' }, - { dim: 'J / P', label: 'Judging vs. Perceiving', desc: 'How you structure your work' }, - ], - workplaceTips: [ - 'Understand why some colleagues prefer email while others prefer face-to-face', - 'Recognize that different communication styles are not personal - they are personality-driven', - 'Build stronger teams by leveraging diverse personality strengths', - 'Reduce workplace conflict by understanding different decision-making approaches', - 'Improve your own self-awareness and professional growth', - ], }, }); export { CONTENT_CATALOG_SEED_PAYLOADS }; + +/** + * Default content rows (content_type → default payload). Single source for both + * the demo seeder and the runtime preset (`ContentCatalogService.seedDefaults + * ForTenant`), which copies the defaults a tenant owns into its rows on creation. + */ +export const CONTENT_CATALOG_DEFAULT_ROWS: ReadonlyArray<{ + readonly content_type: string; + readonly payload: unknown; +}> = Object.freeze([ + { content_type: 'classroom-strategies', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomStrategies }, + { content_type: 'safety-qbs-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyQbsQuiz }, + { content_type: 'sign-language-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguageItems }, + { content_type: 'sign-language-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguagePageContent }, + { content_type: 'regulation-zones', payload: CONTENT_CATALOG_SEED_PAYLOADS.regulationZones }, + { content_type: 'zones-of-regulation-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.zonesOfRegulationPageContent }, + { content_type: 'dashboard-teacher-images', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardTeacherImages }, + { content_type: 'dashboard-encouraging-quotes', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardEncouragingQuotes }, + { content_type: 'dashboard-compliance-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardComplianceItems }, + { content_type: 'dashboard-sign-of-week', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek }, + { content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations }, + { content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities }, + { content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions }, + { content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics }, + { content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips }, + { content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics }, + { content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent }, + { 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 new file mode 100644 index 0000000..c03ec1d --- /dev/null +++ b/backend/src/db/seeders/user-roles.test.ts @@ -0,0 +1,96 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildSeededPermissionNamesForRole } from '@/db/seeders/20200430130760-user-roles'; +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; + +function granted(role: RoleName): readonly string[] { + return buildSeededPermissionNamesForRole(role); +} + +describe('user-role seed permission contract', () => { + test('parent communication is seeded for system admins plus office manager, teacher, and guardian', () => { + const parentCommRoles = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), + ); + + assert.deepEqual(parentCommRoles, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.GUARDIAN, + ]); + }); + + test('registrar has audit/report reads without parent communication', () => { + const permissions = granted(ROLE_NAMES.REGISTRAR); + + assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); + assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS), true); + assert.equal(permissions.includes(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS), true); + }); + + test('internal alerts read/manage grants match tenant leadership and staff expectations', () => { + const internalAlertReaders = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.READ_INTERNAL_COMM), + ); + const internalAlertManagers = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM), + ); + + assert.deepEqual(internalAlertReaders, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, + ]); + assert.deepEqual(internalAlertManagers, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.DIRECTOR, + ]); + }); + + test('leadership full-access grants do not inherit personal workflow permissions', () => { + const ownerPermissions = granted(ROLE_NAMES.OWNER); + const principalPermissions = granted(ROLE_NAMES.PRINCIPAL); + const directorPermissions = granted(ROLE_NAMES.DIRECTOR); + const systemAdminPermissions = granted(ROLE_NAMES.SYSTEM_ADMIN); + + assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); + assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), false); + assert.equal(ownerPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), false); + assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); + assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), false); + assert.equal(principalPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), false); + assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), false); + assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), true); + assert.equal(directorPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); + assert.equal(systemAdminPermissions.includes(FEATURE_PERMISSIONS.READ_PARENT_COMM), true); + assert.equal(systemAdminPermissions.includes(FEATURE_PERMISSIONS.ACK_POLICY), true); + assert.equal(systemAdminPermissions.includes(FEATURE_PERMISSIONS.ZONE_CHECKIN), true); + }); + + test('attendance fill grants match operational writers', () => { + const attendanceFillRoles = Object.values(ROLE_NAMES).filter((role) => + granted(role).includes(FEATURE_PERMISSIONS.FILL_ATTENDANCE), + ); + + assert.deepEqual(attendanceFillRoles, [ + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ]); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 2c58521..6d83f7b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import swaggerUI from 'swagger-ui-express'; import swaggerJsDoc from 'swagger-jsdoc'; import config from '@/shared/config'; import csrfOrigin from '@/middlewares/csrf-origin'; +import { resolveActiveScope } from '@/middlewares/resolve-active-scope'; import { AUTH_COOKIE_NAME, AUTH_REFRESH_COOKIE_NAME, @@ -41,17 +42,21 @@ import authRoutes from '@/routes/auth'; import fileRoutes from '@/routes/file'; import searchRoutes from '@/routes/search'; import publicCampusesRoutes from '@/routes/public_campuses'; -import publicContentCatalogRoutes from '@/routes/public_content_catalog'; import contentCatalogRoutes from '@/routes/content_catalog'; import usersRoutes from '@/routes/users'; import rolesRoutes from '@/routes/roles'; import permissionsRoutes from '@/routes/permissions'; import organizationsRoutes from '@/routes/organizations'; +import iamCapabilitiesRoutes from '@/routes/iam_capabilities'; +import scopeRoutes from '@/routes/scope'; +import platformRoutes from '@/routes/platform'; +import directMessagesRoutes from '@/routes/direct_messages'; +import schoolsRoutes from '@/routes/schools'; import campusesRoutes from '@/routes/campuses'; import academicYearsRoutes from '@/routes/academic_years'; import gradesRoutes from '@/routes/grades'; import subjectsRoutes from '@/routes/subjects'; -import staffRoutes from '@/routes/staff'; +import guardianStudentsRoutes from '@/routes/guardian_students'; import classesRoutes from '@/routes/classes'; import classEnrollmentsRoutes from '@/routes/class_enrollments'; import classSubjectsRoutes from '@/routes/class_subjects'; @@ -70,6 +75,7 @@ import walkthroughCheckinsRoutes from '@/routes/walkthrough_checkins'; import communicationsRoutes from '@/routes/communications'; 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 policyDocumentsRoutes from '@/routes/policy_documents'; import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments'; @@ -78,7 +84,12 @@ import zoneCheckinsRoutes from '@/routes/zone_checkins'; const app = express(); -const authenticated = passport.authenticate('jwt', { session: false }); +// JWT auth, then resolve any drill-down active-tenant header into +// `req.currentUser.activeScope` (validated against the user's scope). +const authenticated = [ + passport.authenticate('jwt', { session: false }), + resolveActiveScope, +]; function getBaseUrl(url: string | undefined): string { if (!url) return ''; @@ -103,8 +114,8 @@ const swaggerOptions: swaggerJsDoc.Options = { '**Authorization is by permission.** Most routes require a', '`${METHOD}_${ENTITY}` CRUD permission (e.g. `READ_USERS`); product', 'feature routes require a product-feature permission (e.g. `READ_FRAME`,', - '`FILL_ATTENDANCE`, `TAKE_QUIZ`). `super_admin`/`system_admin` carry', - 'global access and bypass per-permission checks.', + '`FILL_ATTENDANCE`, `TAKE_QUIZ`). `system_admin` keeps global scope,', + 'while only `super_admin` bypasses standard per-permission checks.', '', '**Generic-CRUD convention.** Entity routers (`users`, `roles`,', '`campuses`, …) expose the same shape: `GET /` (list → `ListResponse`),', @@ -234,18 +245,21 @@ app.use('/api', csrfOrigin); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); app.use('/api/public/campuses', publicCampusesRoutes); -app.use('/api/public/content-catalog', publicContentCatalogRoutes); app.enable('trust proxy'); app.use('/api/users', authenticated, usersRoutes); app.use('/api/roles', authenticated, rolesRoutes); app.use('/api/permissions', authenticated, permissionsRoutes); app.use('/api/organizations', authenticated, organizationsRoutes); +app.use('/api/iam', authenticated, iamCapabilitiesRoutes); +app.use('/api/scope', authenticated, scopeRoutes); +app.use('/api/platform', authenticated, platformRoutes); +app.use('/api/schools', authenticated, schoolsRoutes); app.use('/api/campuses', authenticated, campusesRoutes); app.use('/api/academic_years', authenticated, academicYearsRoutes); app.use('/api/grades', authenticated, gradesRoutes); app.use('/api/subjects', authenticated, subjectsRoutes); -app.use('/api/staff', authenticated, staffRoutes); +app.use('/api/guardian_students', authenticated, guardianStudentsRoutes); app.use('/api/classes', authenticated, classesRoutes); app.use('/api/class_enrollments', authenticated, classEnrollmentsRoutes); app.use('/api/class_subjects', authenticated, classSubjectsRoutes); @@ -262,12 +276,14 @@ app.use('/api/user_progress', authenticated, userProgressRoutes); app.use('/api/safety_quiz_results', authenticated, safetyQuizResultsRoutes); app.use('/api/walkthrough_checkins', authenticated, walkthroughCheckinsRoutes); app.use('/api/communications', authenticated, communicationsRoutes); +app.use('/api/direct_messages', authenticated, directMessagesRoutes); app.use( '/api/personality_quiz_results', authenticated, personalityQuizResultsRoutes, ); app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes); +app.use('/api/class_attendance', authenticated, classAttendanceRoutes); app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes); app.use('/api/content-catalog', authenticated, contentCatalogRoutes); app.use('/api/policy_documents', authenticated, policyDocumentsRoutes); diff --git a/backend/src/middlewares/check-permissions.ts b/backend/src/middlewares/check-permissions.ts index ab0786d..80e4486 100644 --- a/backend/src/middlewares/check-permissions.ts +++ b/backend/src/middlewares/check-permissions.ts @@ -4,6 +4,7 @@ import type { RequestHandler } from 'express'; import ValidationError from '@/shared/errors/validation'; import RolesDBApi from '@/db/api/roles'; import { ROLE_NAMES } from '@/shared/constants/roles'; +import { GLOBAL_BYPASS_EXCLUDED_PERMISSIONS } from '@/shared/constants/product-permissions'; // Cache for the unauthenticated fallback `guest` role, loaded once at startup. let publicRoleCache: Record | null = null; @@ -78,6 +79,16 @@ function roleNameOf(effectiveRole: unknown): string { : 'unknown role'; } +function allowsGlobalBypass(permission: string): boolean { + return !GLOBAL_BYPASS_EXCLUDED_PERMISSIONS.some((name) => name === permission); +} + +function hasPermissionBypass(currentUser: unknown): boolean { + return isRecord(currentUser) + && isRecord(currentUser.app_role) + && currentUser.app_role.name === ROLE_NAMES.SUPER_ADMIN; +} + /** Middleware: allow the request only if the effective role has `permission`. */ function checkPermissions(permission: string): RequestHandler { return async (req, _res, next) => { @@ -95,13 +106,22 @@ function checkPermissions(permission: string): RequestHandler { return next(); } - // 2. Global-access roles (system scope) bypass per-permission checks. - if (currentUser?.app_role?.globalAccess === true) { + // 2. Only the single super-admin bypasses most per-permission checks. + // `system_admin` keeps global scope reach but is now permission-driven. + if (hasPermissionBypass(currentUser) && allowsGlobalBypass(permission)) { return next(); } // 3. Custom (per-user) permissions. if (currentUser) { + const excludedPermissions = currentUser.custom_permissions_filter ?? []; + if (excludedPermissions.some((cp) => cp.name === permission)) { + logger.error( + `User '${currentUser.id}' denied access to excluded permission '${permission}'.`, + ); + return next(new ValidationError('auth.forbidden')); + } + const customPermissions = currentUser.custom_permissions ?? []; if (customPermissions.some((cp) => cp.name === permission)) { return next(); diff --git a/backend/src/middlewares/organization-delete-policy.ts b/backend/src/middlewares/organization-delete-policy.ts new file mode 100644 index 0000000..300d315 --- /dev/null +++ b/backend/src/middlewares/organization-delete-policy.ts @@ -0,0 +1,17 @@ +import type { RequestHandler } from 'express'; +import { assertCanDeleteOrganization } from '@/services/shared/role-policy'; + +/** + * Relational policy: only platform admins and the owner may delete an + * organization. This cannot be represented as a flat DELETE_ORGANIZATIONS + * permission because superintendent intentionally has broad org permissions + * except company deletion. + */ +export const guardOrganizationDelete: RequestHandler = (req, _res, next) => { + try { + assertCanDeleteOrganization(req.currentUser); + next(); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/middlewares/resolve-active-scope.ts b/backend/src/middlewares/resolve-active-scope.ts new file mode 100644 index 0000000..e67d865 --- /dev/null +++ b/backend/src/middlewares/resolve-active-scope.ts @@ -0,0 +1,43 @@ +import type { NextFunction, Request, Response } from 'express'; +import ScopeService from '@/services/scope'; + +const LEVEL_HEADER = 'x-active-tenant-level'; +const ID_HEADER = 'x-active-tenant-id'; + +function headerValue(req: Request, name: string): string | null { + const raw = req.headers[name]; + if (Array.isArray(raw)) return raw[0] ?? null; + return typeof raw === 'string' && raw.length > 0 ? raw : null; +} + +/** + * Drill-down support: if the request carries an active-tenant header + * (`x-active-tenant-level` + `x-active-tenant-id`), validate it against the + * authenticated user's scope and attach the resolved tenant chain to + * `req.currentUser.activeScope`. Downstream scope resolution then acts as that + * tenant. No header → no override (the user's own scope applies). Invalid / + * out-of-scope targets are rejected (403). + */ +export async function resolveActiveScope( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const currentUser = req.currentUser; + const level = headerValue(req, LEVEL_HEADER); + const id = headerValue(req, ID_HEADER); + + if (currentUser && level && id) { + const activeScope = await ScopeService.resolveActiveScope( + level, + id, + currentUser, + ); + req.currentUser = { ...currentUser, activeScope }; + } + next(); + } catch (error) { + next(error); + } +} diff --git a/backend/src/routes/audio_files.ts b/backend/src/routes/audio_files.ts index 059bbe1..6d7248f 100644 --- a/backend/src/routes/audio_files.ts +++ b/backend/src/routes/audio_files.ts @@ -17,7 +17,7 @@ const canManage = permissions.checkPermissions( * get: * tags: [Audio Library] * summary: List playable audio (campus uploads + global defaults) - * description: Requires READ_AUDIO_FILES (the four campus staff roles). + * description: Requires READ_AUDIO_FILES. * responses: * 200: * description: Audio files. diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 98729a1..a5b6dc8 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -100,6 +100,19 @@ router.post('/signout', wrapAsync(auth.signout)); */ router.get('/me', authenticated, wrapAsync(auth.me)); +/** + * @openapi + * /api/auth/me: + * put: + * tags: [Auth] + * summary: Update the signed-in user's own profile (honorific/name/email) + * description: Self-service. Role/tenant are not self-editable. + * responses: + * 200: { description: Updated profile. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.put('/me', authenticated, wrapAsync(auth.updateMe)); + /** * @openapi * /api/auth/password-reset: diff --git a/backend/src/routes/class_attendance.ts b/backend/src/routes/class_attendance.ts new file mode 100644 index 0000000..6872773 --- /dev/null +++ b/backend/src/routes/class_attendance.ts @@ -0,0 +1,49 @@ +/** + * @openapi + * /api/class_attendance/summary: + * get: + * tags: [ClassAttendance] + * summary: Roll-up attendance totals for the caller's tier (per date) + * description: Requires READ_ATTENDANCE. Sums class attendance across the caller's subtree (org/school/campus/class). + * responses: + * 200: { description: Roll-up rows. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_attendance/{classId}/{date}: + * put: + * tags: [ClassAttendance] + * summary: Record a class's daily attendance aggregate (teacher) + * description: Requires FILL_ATTENDANCE. Rolls up to campus/school/org. + * parameters: + * - in: path + * name: classId + * required: true + * schema: { type: string, format: uuid } + * - in: path + * name: date + * required: true + * schema: { type: string, format: date } + * responses: + * 200: { description: Saved. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import * as classAttendance from '@/api/controllers/class_attendance.controller'; + +const router = express.Router(); + +router.get( + '/summary', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), + wrapAsync(classAttendance.summary), +); +router.put( + '/:classId/:date', + permissions.checkPermissions(FEATURE_PERMISSIONS.FILL_ATTENDANCE), + wrapAsync(classAttendance.upsert), +); + +export default router; diff --git a/backend/src/routes/communications.ts b/backend/src/routes/communications.ts index 74bb578..8e4b6da 100644 --- a/backend/src/routes/communications.ts +++ b/backend/src/routes/communications.ts @@ -8,24 +8,6 @@ const router = express.Router(); /** * @openapi - * /api/communications/parent-messages: - * get: - * tags: [Communications] - * summary: List parent messages - * description: Requires `READ_PARENT_COMM`. - * responses: - * 200: - * description: Parent messages. - * content: - * application/json: - * schema: { $ref: '#/components/schemas/ListResponse' } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * post: - * tags: [Communications] - * summary: Send a parent message (manager-only; service-gated) - * responses: - * 200: { description: Sent. } - * 403: { $ref: '#/components/responses/ForbiddenError' } * /api/communications/events: * get: * tags: [Communications] @@ -45,17 +27,14 @@ const router = express.Router(); * 200: { description: Created. } * 403: { $ref: '#/components/responses/ForbiddenError' } */ -router.get( - '/parent-messages', - permissions.checkPermissions(FEATURE_PERMISSIONS.READ_PARENT_COMM), - wrapAsync(communications.listParentMessages), -); -router.post('/parent-messages', wrapAsync(communications.createParentMessage)); router.get( '/events', permissions.checkPermissions(FEATURE_PERMISSIONS.READ_INTERNAL_COMM), wrapAsync(communications.listEvents), ); router.post('/events', wrapAsync(communications.createEvent)); +router.patch('/events/:id', wrapAsync(communications.updateEvent)); +router.delete('/events/:id', wrapAsync(communications.deleteEvent)); +router.post('/events/:id/cancel', wrapAsync(communications.cancelEvent)); export default router; diff --git a/backend/src/routes/content_catalog.ts b/backend/src/routes/content_catalog.ts index c24e865..14e5ef8 100644 --- a/backend/src/routes/content_catalog.ts +++ b/backend/src/routes/content_catalog.ts @@ -59,6 +59,9 @@ const router = express.Router(); */ router.get('/', wrapAsync(content_catalog.list)); router.post('/', wrapAsync(content_catalog.create)); +// Authenticated read for any tenant user — returns content scoped to the user +// (replaces the removed unauthenticated public path). +router.get('/read/:contentType', wrapAsync(content_catalog.readByType)); router.get('/:contentType', wrapAsync(content_catalog.findManagedByType)); router.put('/:contentType', wrapAsync(content_catalog.update)); router.delete('/:contentType', wrapAsync(content_catalog.remove)); diff --git a/backend/src/routes/direct_messages.ts b/backend/src/routes/direct_messages.ts new file mode 100644 index 0000000..7276a61 --- /dev/null +++ b/backend/src/routes/direct_messages.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import * as directMessages from '@/api/controllers/direct_messages.controller'; + +const router = express.Router(); +router.use(permissions.checkPermissions(FEATURE_PERMISSIONS.READ_PARENT_COMM)); + +/** + * @openapi + * tags: + * - name: DirectMessages + * description: > + * 1:1 conversations between staff (teacher / office_manager) and guardians, + * discovered through a shared student. Access is membership-based — a user + * only sees conversations they participate in. + */ + +/** + * @openapi + * /api/direct_messages/contacts: + * get: + * tags: [DirectMessages] + * summary: People the current user can start/continue a chat with (via a student) + * responses: + * 200: { description: Contacts. } + */ +router.get('/contacts', wrapAsync(directMessages.contacts)); + +/** + * @openapi + * /api/direct_messages/conversations: + * get: + * tags: [DirectMessages] + * summary: The current user's conversations (one per counterpart + student) + * responses: + * 200: { description: Conversations. } + */ +router.get('/conversations', wrapAsync(directMessages.conversations)); + +/** + * @openapi + * /api/direct_messages/thread/{otherUserId}: + * get: + * tags: [DirectMessages] + * summary: Messages between the current user and another user for one student (marks read) + * responses: + * 200: { description: Thread. } + */ +router.get('/thread/:otherUserId', wrapAsync(directMessages.thread)); + +/** + * @openapi + * /api/direct_messages/send: + * post: + * tags: [DirectMessages] + * summary: Send a message to a contact + * responses: + * 200: { description: The created message. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.post('/send', wrapAsync(directMessages.send)); + +export default router; diff --git a/backend/src/routes/file.ts b/backend/src/routes/file.ts index f80eed6..0df1294 100644 --- a/backend/src/routes/file.ts +++ b/backend/src/routes/file.ts @@ -5,10 +5,9 @@ import * as file from '@/api/controllers/file.controller'; const router = express.Router(); -// Authentication required (Workstream 3 §3.5 / WS5): downloads serve files by -// path, so an unauthenticated endpoint would leak private files. Download also -// enforces a per-file tenant/ownership check in the controller (a user may only -// fetch a file owned by their own organization unless they have global access). +// Authentication required: downloads serve files by path, so an unauthenticated +// endpoint would leak private files. Per-file ownership checks were removed by +// customer decision; `assertCanDownloadFile` now enforces JWT-only access. router.get( '/download', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/guardian_students.ts b/backend/src/routes/guardian_students.ts new file mode 100644 index 0000000..53a8d41 --- /dev/null +++ b/backend/src/routes/guardian_students.ts @@ -0,0 +1,56 @@ +/** + * @openapi + * /api/guardian_students: + * get: + * tags: [GuardianStudents] + * summary: List guardian↔student links (filter by studentId or guardianId) + * description: Requires READ_GUARDIAN_STUDENTS. + * responses: + * 200: { description: List payload. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [GuardianStudents] + * summary: Link a guardian to a student (idempotent) + * description: Requires CREATE_GUARDIAN_STUDENTS. + * responses: + * 200: { description: Linked. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/guardian_students/{id}: + * delete: + * tags: [GuardianStudents] + * summary: Remove a guardian↔student link + * description: Requires DELETE_GUARDIAN_STUDENTS. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Removed. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import * as guardianStudents from '@/api/controllers/guardian_students.controller'; + +const router = express.Router(); + +router.get( + '/', + permissions.checkPermissions('READ_GUARDIAN_STUDENTS'), + wrapAsync(guardianStudents.list), +); +router.post( + '/', + permissions.checkPermissions('CREATE_GUARDIAN_STUDENTS'), + wrapAsync(guardianStudents.link), +); +router.delete( + '/:id', + permissions.checkPermissions('DELETE_GUARDIAN_STUDENTS'), + wrapAsync(guardianStudents.unlink), +); + +export default router; diff --git a/backend/src/routes/iam_capabilities.ts b/backend/src/routes/iam_capabilities.ts new file mode 100644 index 0000000..ff73b0a --- /dev/null +++ b/backend/src/routes/iam_capabilities.ts @@ -0,0 +1,22 @@ +/** + * @openapi + * /api/iam/capabilities: + * get: + * tags: [IAM] + * summary: Current user's derived IAM capabilities + * description: > + * Returns role-hierarchy and tenant-creation capabilities derived from + * effective permissions plus backend role-policy. Frontend forms use this + * instead of owning role-name allowlists. + * responses: + * 200: { description: Capability payload. } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ +import express from 'express'; +import { current } from '@/api/controllers/iam_capabilities.controller'; + +const router = express.Router(); + +router.get('/capabilities', current); + +export default router; diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index 81b1dec..51c9e30 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -91,28 +91,13 @@ * 200: { description: Deleted. } * 403: { $ref: '#/components/responses/ForbiddenError' } */ -import express, { type RequestHandler } from 'express'; +import express from 'express'; import controller from '@/api/controllers/organizations.controller'; import { createCrudRouter } from '@/api/http/crud-router'; -import { assertCanDeleteOrganization } from '@/services/shared/role-policy'; +import { guardOrganizationDelete } from '@/middlewares/organization-delete-policy'; const router = express.Router(); -/** - * Relational policy (Workstream 3 §3.3): only `super_admin` / `system_admin` / - * `owner` may delete a company. A `superintendent` is blocked even though it - * holds `DELETE_ORGANIZATIONS` — the "cannot delete the company" rule cannot be - * expressed as a flat permission. Runs before the generic CRUD permission check. - */ -const guardOrganizationDelete: RequestHandler = (req, _res, next) => { - try { - assertCanDeleteOrganization(req.currentUser); - next(); - } catch (error) { - next(error); - } -}; - router.delete('/:id', guardOrganizationDelete); router.post('/deleteByIds', guardOrganizationDelete); diff --git a/backend/src/routes/personality_quiz_results.ts b/backend/src/routes/personality_quiz_results.ts index f8374e9..2b60719 100644 --- a/backend/src/routes/personality_quiz_results.ts +++ b/backend/src/routes/personality_quiz_results.ts @@ -25,7 +25,7 @@ const router = express.Router(); * /api/personality_quiz_results/distribution: * get: * tags: [Quizzes] - * summary: Personality distribution report (report-eligible roles) + * summary: Personality distribution report * responses: * 200: { description: Distribution. } * 403: { $ref: '#/components/responses/ForbiddenError' } diff --git a/backend/src/routes/platform.ts b/backend/src/routes/platform.ts new file mode 100644 index 0000000..c989efb --- /dev/null +++ b/backend/src/routes/platform.ts @@ -0,0 +1,24 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as platform from '@/api/controllers/platform.controller'; + +const router = express.Router(); + +/** + * @openapi + * /api/platform/stats: + * get: + * tags: [Platform] + * summary: Platform-wide statistics (global scope only) + * description: > + * Unscoped counts for the platform operator: tenants (organizations, + * schools, campuses, classes), users (total + by role), content (FRAME + * entries, quizzes, documents), and active sessions. Restricted to + * super_admin / system_admin. + * responses: + * 200: { description: Platform statistics. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get('/stats', wrapAsync(platform.stats)); + +export default router; diff --git a/backend/src/routes/policy_acknowledgments.ts b/backend/src/routes/policy_acknowledgments.ts index f665900..b372d6f 100644 --- a/backend/src/routes/policy_acknowledgments.ts +++ b/backend/src/routes/policy_acknowledgments.ts @@ -6,9 +6,10 @@ import * as policy_acknowledgments from '@/api/controllers/policy_acknowledgment const router = express.Router(); -// Acknowledging policies is restricted to the campus staff roles via the -// ACK_POLICY product-feature permission; the service scopes reads to the -// caller's own acknowledgments. +router.get('/report', wrapAsync(policy_acknowledgments.report)); + +// Acknowledging policies requires explicit ACK_POLICY; the service scopes reads +// to the caller's own acknowledgments. router.use(permissions.checkPermissions(FEATURE_PERMISSIONS.ACK_POLICY)); /** @@ -17,7 +18,7 @@ router.use(permissions.checkPermissions(FEATURE_PERMISSIONS.ACK_POLICY)); * get: * tags: [Policy Documents] * summary: List the current user's policy acknowledgments - * description: Requires the ACK_POLICY permission (the four campus staff roles). + * description: Requires explicit ACK_POLICY. * responses: * 200: * description: The caller's acknowledgments. diff --git a/backend/src/routes/policy_documents.ts b/backend/src/routes/policy_documents.ts index a6e3b04..55abcfb 100644 --- a/backend/src/routes/policy_documents.ts +++ b/backend/src/routes/policy_documents.ts @@ -7,7 +7,7 @@ import { createCrudRouter } from '@/api/http/crud-router'; * get: * tags: [Policy Documents] * summary: List policy/safety documents (tenant/campus-scoped) - * description: Requires READ_POLICY_DOCUMENTS (the four campus staff roles). + * description: Requires READ_POLICY_DOCUMENTS. * responses: * 200: * description: List payload. diff --git a/backend/src/routes/public_content_catalog.ts b/backend/src/routes/public_content_catalog.ts deleted file mode 100644 index 7ca4fd1..0000000 --- a/backend/src/routes/public_content_catalog.ts +++ /dev/null @@ -1,25 +0,0 @@ -import express from 'express'; -import { wrapAsync } from '@/api/http/request'; -import * as public_content_catalog from '@/api/controllers/public_content_catalog.controller'; - -const router = express.Router(); - -/** - * @openapi - * /api/public/content-catalog/{contentType}: - * get: - * tags: [Public] - * summary: Get published content for a content type (public-by-design) - * description: Unauthenticated; serves only published, non-tenant content. - * security: [] - * parameters: - * - in: path - * name: contentType - * required: true - * schema: { type: string } - * responses: - * 200: { description: Published content for the type. } - */ -router.get('/:contentType', wrapAsync(public_content_catalog.findByType)); - -export default router; diff --git a/backend/src/routes/safety_quiz_results.ts b/backend/src/routes/safety_quiz_results.ts index a8a65b1..d00cb69 100644 --- a/backend/src/routes/safety_quiz_results.ts +++ b/backend/src/routes/safety_quiz_results.ts @@ -14,7 +14,7 @@ const router = express.Router(); * summary: List safety (QBS) quiz results * description: > * Authenticated; the service returns own results, or the compliance report - * for report-eligible roles. + * for users with report-read permission. * responses: * 200: * description: Results. diff --git a/backend/src/routes/schools.ts b/backend/src/routes/schools.ts new file mode 100644 index 0000000..293a93b --- /dev/null +++ b/backend/src/routes/schools.ts @@ -0,0 +1,70 @@ +/** + * @openapi + * /api/schools: + * get: + * tags: [Schools] + * summary: List schools (tenant-scoped) + * description: Requires READ_SCHOOLS. + * responses: + * 200: + * description: List payload. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Schools] + * summary: Create a schools record + * description: Requires CREATE_SCHOOLS and system/organization scope. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/schools/{id}: + * put: + * tags: [Schools] + * summary: Update a schools record + * description: Requires UPDATE_SCHOOLS and system/organization scope. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Updated. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * delete: + * tags: [Schools] + * summary: Delete a schools record + * description: Requires DELETE_SCHOOLS and system/organization scope. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import controller, { + createWithFirstCampus, +} from '@/api/controllers/schools.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +const router = express.Router(); + +// Custom atomic create (school + first campus) — must precede the generic CRUD +// router so it is matched before the generic `POST /` and `/:id` routes. +router.post( + '/with-first-campus', + permissions.checkPermissions('CREATE_SCHOOLS'), + wrapAsync(createWithFirstCampus), +); + +router.use(createCrudRouter(controller, { permission: 'schools' })); + +export default router; diff --git a/backend/src/routes/scope.ts b/backend/src/routes/scope.ts new file mode 100644 index 0000000..4852a96 --- /dev/null +++ b/backend/src/routes/scope.ts @@ -0,0 +1,33 @@ +/** + * @openapi + * /api/scope/children: + * get: + * tags: [Scope] + * summary: Tenants the user can drill into below a parent (or their own tenant) + * description: > + * Returns org → schools → campuses → classes one level below `parentLevel`/ + * `parentId` (or below the caller's own tenant when omitted), scoped to the + * caller. Used by the tenant switcher / drill-down. + * parameters: + * - in: query + * name: parentLevel + * schema: { type: string, enum: [organization, school, campus] } + * - in: query + * name: parentId + * schema: { type: string, format: uuid } + * - in: query + * name: limit + * schema: { type: integer, minimum: 1 } + * responses: + * 200: { description: Child tenants. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as scope from '@/api/controllers/scope.controller'; + +const router = express.Router(); + +router.get('/children', wrapAsync(scope.children)); + +export default router; diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts deleted file mode 100644 index b9bf2fd..0000000 --- a/backend/src/routes/staff.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @openapi - * /api/staff: - * get: - * tags: [Staff] - * summary: List staff (tenant-scoped) - * description: Requires READ_STAFF. - * responses: - * 200: - * description: List payload. - * content: - * application/json: - * schema: { $ref: '#/components/schemas/ListResponse' } - * 401: { $ref: '#/components/responses/UnauthorizedError' } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * post: - * tags: [Staff] - * summary: Create a staff record - * description: Requires CREATE_STAFF. - * responses: - * 200: { description: Created. } - * 400: { $ref: '#/components/responses/ValidationError' } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * /api/staff/count: - * get: - * tags: [Staff] - * summary: Count staff - * description: Requires READ_STAFF. - * responses: - * 200: { description: Count. } - * /api/staff/autocomplete: - * get: - * tags: [Staff] - * summary: Autocomplete staff - * description: Requires READ_STAFF. - * responses: - * 200: { description: Autocomplete results. } - * /api/staff/bulk-import: - * post: - * tags: [Staff] - * summary: Bulk-import staff from CSV - * description: Requires CREATE_STAFF. - * responses: - * 200: { description: Imported. } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * /api/staff/deleteByIds: - * post: - * tags: [Staff] - * summary: Delete multiple staff records - * description: Requires DELETE_STAFF. - * responses: - * 200: { description: Deleted. } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * /api/staff/{id}: - * get: - * tags: [Staff] - * summary: Get a staff record by id - * description: Requires READ_STAFF. - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: { description: The record. } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * 404: { description: Not found. } - * put: - * tags: [Staff] - * summary: Update a staff record - * description: Requires UPDATE_STAFF. - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: { description: Updated. } - * 400: { $ref: '#/components/responses/ValidationError' } - * 403: { $ref: '#/components/responses/ForbiddenError' } - * delete: - * tags: [Staff] - * summary: Delete a staff record - * description: Requires DELETE_STAFF. - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string, format: uuid } - * responses: - * 200: { description: Deleted. } - * 403: { $ref: '#/components/responses/ForbiddenError' } - */ -import controller from '@/api/controllers/staff.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'staff' }); diff --git a/backend/src/routes/staff_attendance.ts b/backend/src/routes/staff_attendance.ts index fa0ee48..7f6d827 100644 --- a/backend/src/routes/staff_attendance.ts +++ b/backend/src/routes/staff_attendance.ts @@ -12,7 +12,7 @@ const router = express.Router(); * get: * tags: [Staff Attendance] * summary: List staff attendance records (report) - * description: Requires `READ_ATTENDANCE`; the service limits the payload to report-eligible roles. + * description: Requires `READ_ATTENDANCE`; the service limits the payload to the report population. * responses: * 200: { description: Records. } * 403: { $ref: '#/components/responses/ForbiddenError' } @@ -24,6 +24,14 @@ const router = express.Router(); * responses: * 200: { description: Summary. } * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/staff_attendance/records/{userId}/{date}: + * put: + * tags: [Staff Attendance] + * summary: Upsert a staff attendance record. + * description: Requires `FILL_ATTENDANCE`. + * responses: + * 200: { description: Upserted record. } + * 403: { $ref: '#/components/responses/ForbiddenError' } */ router.get( '/records', @@ -35,5 +43,10 @@ router.get( permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), wrapAsync(staff_attendance.summary), ); +router.put( + '/records/:userId/:date', + permissions.checkPermissions(FEATURE_PERMISSIONS.FILL_ATTENDANCE), + wrapAsync(staff_attendance.upsertRecord), +); export default router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index e718961..ad618e7 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -100,6 +100,11 @@ const router = express.Router(); router.use(permissions.checkCrudPermissions('users')); +router.post( + '/owner-with-organization', + permissions.checkPermissions('CREATE_ORGANIZATIONS'), + wrapAsync(users.createOwnerWithOrganization), +); router.post('/', wrapAsync(users.create)); router.post('/bulk-import', wrapAsync(users.bulkImport)); router.put('/:id', wrapAsync(users.update)); diff --git a/backend/src/routes/zone_checkins.ts b/backend/src/routes/zone_checkins.ts index 8d348f1..02ffcd6 100644 --- a/backend/src/routes/zone_checkins.ts +++ b/backend/src/routes/zone_checkins.ts @@ -15,7 +15,7 @@ const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN * tags: [Zone Check-in] * summary: Today's zone check-in for the caller (campus-local date) * description: > - * Requires ZONE_CHECKIN (the four campus staff roles). "Today" is the + * Requires explicit ZONE_CHECKIN. "Today" is the * caller's campus-local date (campus `timezone`), computed server-side. * responses: * 200: diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index ed8b78c..df18fb4 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -12,19 +12,23 @@ import InvitationEmail from '@/services/email/list/invitation'; import PasswordResetEmail from '@/services/email/list/passwordReset'; import EmailSender from '@/services/email'; import config from '@/shared/config'; +import { + USER_NAME_PREFIX_VALUES, + type UserNamePrefix, +} from '@/shared/constants/users'; import { jwtSign } from '@/shared/jwt'; import db from '@/db/models'; import type { AuthenticatedUser, CurrentUser, DbApiOptions, + FileInput, } from '@/db/api/types'; import type { SessionOptions, RoleDto, OrganizationDto, CampusDto, - StaffProfileDto, } from '@/services/auth.types'; type PlainRecord = Record; @@ -73,6 +77,7 @@ function toOrganizationDto(organization: unknown): OrganizationDto | null { return { id, name: asStringOrNull(plain.name), + logo: asStringOrNull(plain.logo), }; } @@ -87,42 +92,48 @@ function toCampusDto(campus: unknown): CampusDto | null { }; } -function toStaffProfileDto(staffProfile: unknown): StaffProfileDto | null { - const plain = toPlainRecord(staffProfile); +/** Minimal tenant identity for the active-scope badge / selectors. */ +function toTenantDto( + value: unknown, +): { id: string; name: string | null; logo: string | null } | null { + const plain = toPlainRecord(value); const id = asStringOrNull(plain?.id); if (!plain || !id) return null; return { id, - employee_number: asStringOrNull(plain.employee_number), - job_title: asStringOrNull(plain.job_title), - staff_type: asStringOrNull(plain.staff_type), - status: asStringOrNull(plain.status), - organizationId: asStringOrNull(plain.organizationId), - campusId: asStringOrNull(plain.campusId), - userId: asStringOrNull(plain.userId), + name: asStringOrNull(plain.name), + logo: asStringOrNull(plain.logo), }; } -function getPermissionNames( - rolePermissions: unknown, - customPermissions: unknown, -): string[] { - const appRolePermissions: unknown[] = Array.isArray(rolePermissions) - ? rolePermissions - : []; - const custom: unknown[] = Array.isArray(customPermissions) - ? customPermissions - : []; - - const names = [...appRolePermissions, ...custom] +function permissionNamesOf(value: unknown): string[] { + return (Array.isArray(value) ? value : []) .map((permission) => toPlainRecord(permission)) .filter( (permission): permission is { name: string } => isRecord(permission) && typeof permission.name === 'string', ) .map((permission) => permission.name); +} - return [...new Set(names)]; +/** + * Effective permissions = (role ∪ per-user grants) − per-user exclusions. The + * `custom_permissions_filter` lets a manager remove specific permissions from a + * user that their role would otherwise grant. + */ +function getPermissionNames( + rolePermissions: unknown, + customPermissions: unknown, + filterPermissions: unknown = [], +): string[] { + const granted = new Set([ + ...permissionNamesOf(rolePermissions), + ...permissionNamesOf(customPermissions), + ]); + for (const excluded of permissionNamesOf(filterPermissions)) { + granted.delete(excluded); + } + return [...granted]; } function getTokenPayload(user: SessionUser) { @@ -172,10 +183,7 @@ class Auth { throw new ForbiddenError(); } - const staffProfile: unknown = - user.staff_user.length > 0 ? user.staff_user[0] : null; - const staffProfileDto = toStaffProfileDto(staffProfile); - const campusDto = toCampusDto(user.staff_campus); + const campusDto = toCampusDto(user.campus); return { id: user.id, @@ -183,15 +191,22 @@ class Auth { name_prefix: user.name_prefix ?? null, firstName: user.firstName, lastName: user.lastName, + phoneNumber: user.phoneNumber, + avatar: user.avatar?.[0]?.privateUrl ?? null, organizationId: user.organizationId, organizations: toOrganizationDto(user.organizations), app_role: toRoleDto(user.app_role), - staffProfile: staffProfileDto, campus: campusDto, - campusId: campusDto ? campusDto.id : (staffProfileDto?.campusId ?? null), + campusId: user.campusId ?? campusDto?.id ?? null, + // Active-scope tenant chain (for the dynamic badge + scope context). + school: toTenantDto(user.school), + schoolId: user.schoolId ?? null, + classRoom: toTenantDto(user.class), + classId: user.classId ?? null, permissions: getPermissionNames( user.app_role_permissions, user.custom_permissions, + user.custom_permissions_filter, ), }; } @@ -472,6 +487,73 @@ class Auth { return UsersDBApi.updatePassword(asId(currentUser.id), hashedPassword, options); } + /** + * Self-service profile update for the signed-in user: honorific, name, + * phone, and email. Role/tenant/disabled are NOT self-editable (a boss changes + * those via the users admin). Returns the refreshed profile. + */ + static async updateOwnProfile( + data: { + name_prefix?: unknown; + firstName?: unknown; + lastName?: unknown; + phoneNumber?: unknown; + email?: unknown; + avatar?: unknown; + }, + options: DbApiOptions, + ) { + const currentUser = options.currentUser ?? null; + if (!currentUser?.id) { + throw new ForbiddenError(); + } + + const updates: { + name_prefix?: UserNamePrefix | null; + firstName?: string; + lastName?: string; + phoneNumber?: string | null; + email?: string; + avatar?: FileInput | FileInput[] | null; + } = {}; + + if (data?.name_prefix === null) { + updates.name_prefix = null; + } else if (typeof data?.name_prefix === 'string') { + const prefix = USER_NAME_PREFIX_VALUES.find((p) => p === data.name_prefix); + if (prefix) { + updates.name_prefix = prefix; + } + } + if (typeof data?.firstName === 'string') { + updates.firstName = data.firstName.trim(); + } + if (typeof data?.lastName === 'string') { + updates.lastName = data.lastName.trim(); + } + if (data?.phoneNumber === null) { + updates.phoneNumber = null; + } else if (typeof data?.phoneNumber === 'string') { + updates.phoneNumber = data.phoneNumber.trim() || null; + } + if (typeof data?.email === 'string' && data.email.trim().length > 0) { + updates.email = data.email.trim(); + } + // Avatar: a privateUrl string from the file upload becomes a new file + // relation; `null` clears it. The download endpoint serves it by privateUrl, + // so publicUrl mirrors it for local storage. + if (data?.avatar === null) { + updates.avatar = []; + } else if (typeof data?.avatar === 'string' && data.avatar.length > 0) { + const privateUrl = data.avatar; + const name = privateUrl.split('/').pop() || 'avatar'; + updates.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }]; + } + + await UsersDBApi.update(asId(currentUser.id), updates, false, options); + return this.currentUserProfile(currentUser); + } + static async passwordReset( token: string, password: string, diff --git a/backend/src/services/auth.types.ts b/backend/src/services/auth.types.ts index a2bd583..3568483 100644 --- a/backend/src/services/auth.types.ts +++ b/backend/src/services/auth.types.ts @@ -16,6 +16,7 @@ export interface RoleDto { export interface OrganizationDto { id: string; name: string | null; + logo?: string | null; } export interface CampusDto { @@ -23,14 +24,3 @@ export interface CampusDto { name: string | null; code: string | null; } - -export interface StaffProfileDto { - id: string; - employee_number: string | null; - job_title: string | null; - staff_type: string | null; - status: string | null; - organizationId: string | null; - campusId: string | null; - userId: string | null; -} diff --git a/backend/src/services/campus_attendance.test.ts b/backend/src/services/campus_attendance.test.ts new file mode 100644 index 0000000..ab86842 --- /dev/null +++ b/backend/src/services/campus_attendance.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import CampusAttendanceService from '@/services/campus_attendance'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { createTestUser } from '@/test-utils'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('CampusAttendanceService', () => { + test('resolves active campus scope id before checking campus_key access', async () => { + const organizationId = '11111111-1111-4111-8111-111111111111'; + const campusId = '33333333-3333-4333-8333-333333333333'; + const currentUser = createTestUser({ + organizationId, + organizations: { id: organizationId }, + campusId: null, + campus: null, + activeScope: { + level: ROLE_SCOPES.CAMPUS, + organizationId, + schoolId: '22222222-2222-4222-8222-222222222222', + campusId, + classId: null, + }, + app_role: { + name: ROLE_NAMES.SUPER_ADMIN, + scope: ROLE_SCOPES.SYSTEM, + globalAccess: true, + permissions: [], + }, + }); + let capturedWhere: unknown = null; + + mock.method(db.campuses, 'findOne', async () => ({ + get: () => ({ code: 'TIGERS', name: 'Tigers Campus' }), + })); + mock.method(db.campus_attendance_config, 'findAndCountAll', async (options: unknown) => { + if (isRecord(options)) { + capturedWhere = options.where; + } + return { rows: [], count: 0 }; + }); + + await CampusAttendanceService.listConfigs({ campusKey: 'tigers' }, currentUser); + + assert.equal(isRecord(capturedWhere), true); + if (!isRecord(capturedWhere)) { + return; + } + assert.equal(capturedWhere.organizationId, organizationId); + assert.equal(capturedWhere.campus_key, 'tigers'); + }); +}); diff --git a/backend/src/services/campus_attendance.ts b/backend/src/services/campus_attendance.ts index 755ee1e..13bc48d 100644 --- a/backend/src/services/campus_attendance.ts +++ b/backend/src/services/campus_attendance.ts @@ -6,7 +6,6 @@ import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; import { CAMPUS_ATTENDANCE_DEFAULT_LIMIT, - CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, CAMPUS_ATTENDANCE_MAX_LIMIT, normalizeCampusKey, } from '@/shared/constants/campus-attendance'; @@ -25,32 +24,62 @@ import { getCampusId, getRoleScope, getSchoolId, + hasFeaturePermission, hasGlobalAccess, - hasRoleAccess, requireOrganizationId, requireUserId, getDisplayName, } from '@/services/shared/access'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; /** UUIDs are inlined into a SQL literal subquery; reject anything that is not one. */ const CA_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; -function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null { - const staff = currentUser?.staff_user; - const staffProfile = Array.isArray(staff) ? staff[0] : null; - +function getEmbeddedCampusKey(currentUser?: CurrentUser): string | null { return ( normalizeCampusKey(currentUser?.campus?.code) || normalizeCampusKey(currentUser?.campus?.name) || - normalizeCampusKey(staffProfile?.campus?.code) || - normalizeCampusKey(staffProfile?.campus?.name) || null ); } +async function getCurrentUserCampusKey(currentUser?: CurrentUser): Promise { + const embeddedCampusKey = getEmbeddedCampusKey(currentUser); + if (embeddedCampusKey) { + return embeddedCampusKey; + } + + const campusId = getCampusId(currentUser); + if (!campusId) { + return null; + } + + const campus = await db.campuses.findOne({ + attributes: ['code', 'name'], + where: { + id: campusId, + organizationId: requireOrganizationId(currentUser), + }, + }); + + if (!campus) { + return null; + } + + const plain = campus.get({ plain: true }) as { + code?: string | null; + name?: string | null; + }; + + return normalizeCampusKey(plain.code) || normalizeCampusKey(plain.name) || null; +} + function canManageCampusAttendance(currentUser?: CurrentUser): boolean { - return hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES); + return hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.FILL_ATTENDANCE, + ); } function assertCanManageCampusAttendance(currentUser?: CurrentUser): void { @@ -140,7 +169,7 @@ async function assertCanAccessCampusKey( throw new ForbiddenError(); } - const currentCampusKey = getCurrentUserCampusKey(currentUser); + const currentCampusKey = await getCurrentUserCampusKey(currentUser); if (currentCampusKey && currentCampusKey === campusKey) { return; } @@ -149,7 +178,7 @@ async function assertCanAccessCampusKey( } /** Resolves the campus_key scope by tier, asserting access along the way. */ -async function campusScope( +async function campusKeyScope( filter: CampusAttendanceFilter, currentUser?: CurrentUser, ): Promise<{ campus_key?: string | { [Op.in]: ReturnType } }> { @@ -164,7 +193,7 @@ async function campusScope( return {}; } - // School roles (Principal/Registrar): every campus in their school. + // School scope: every campus in the current school. if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) { const schoolId = getSchoolId(currentUser); if (!schoolId) { @@ -173,7 +202,7 @@ async function campusScope( return { campus_key: { [Op.in]: schoolCampusKeySubquery(schoolId) } }; } - const currentCampusKey = getCurrentUserCampusKey(currentUser); + const currentCampusKey = await getCurrentUserCampusKey(currentUser); if (!currentCampusKey) { throw new ForbiddenError(); @@ -296,7 +325,7 @@ class CampusAttendanceService { const result = await db.campus_attendance_config.findAndCountAll({ where: { organizationId: requireOrganizationId(currentUser), - ...(await campusScope(filter, currentUser)), + ...(await campusKeyScope(filter, currentUser)), }, order: [['campus_key', 'asc']], limit, @@ -359,7 +388,7 @@ class CampusAttendanceService { const result = await db.campus_attendance_summaries.findAndCountAll({ where: { organizationId: requireOrganizationId(currentUser), - ...(await campusScope(filter, currentUser)), + ...(await campusKeyScope(filter, currentUser)), ...dateRange(filter), }, limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT), diff --git a/backend/src/services/campuses.ts b/backend/src/services/campuses.ts index faea810..a616dfc 100644 --- a/backend/src/services/campuses.ts +++ b/backend/src/services/campuses.ts @@ -1,4 +1,32 @@ import DbApi from '@/db/api/campuses'; import { createCrudService } from '@/services/shared/crud-service'; +import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; +import { withTransaction } from '@/db/with-transaction'; +import type { CurrentUser } from '@/db/api/types'; -export default createCrudService(DbApi, { notFoundCode: 'campusesNotFound' }); +const base = createCrudService(DbApi, { notFoundCode: 'campusesNotFound' }); + +export default { + ...base, + /** + * Creates a campus and presets its campus-scoped default content in the same + * transaction. Idempotent; skipped when the campus has no organization (e.g. + * a global-scope creator with no org context). The school's first campus is + * seeded by SchoolsService.createWithFirstCampus, not here. + */ + async create( + data: Parameters[0], + currentUser?: CurrentUser, + ): Promise { + await withTransaction(async (transaction) => { + const campus = await DbApi.create(data, { currentUser, transaction }); + const organizationId = campus.organizationId; + if (organizationId) { + await seedDefaultContentForTenant( + { level: 'campus', organizationId, campusId: campus.id }, + transaction, + ); + } + }); + }, +}; diff --git a/backend/src/services/class_attendance.ts b/backend/src/services/class_attendance.ts new file mode 100644 index 0000000..bf3853a --- /dev/null +++ b/backend/src/services/class_attendance.ts @@ -0,0 +1,239 @@ +import { fn, col, Op } from 'sequelize'; +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import { requiredIsoDate } from '@/services/shared/validate'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { ROLE_SCOPES } from '@/shared/constants/roles'; +import { + assertAuthenticatedTenantUser, + getDisplayName, + getRoleScope, + getOrganizationId, + getSchoolId, + getCampusId, + getClassId, + hasGlobalAccess, + requireUserId, +} from '@/services/shared/access'; +import type { ClassAttendance } from '@/db/models/class_attendance'; +import type { CurrentUser } from '@/db/api/types'; + +interface ClassAttendanceInput { + total_enrolled?: unknown; + total_present?: unknown; + total_absent?: unknown; + total_tardy?: unknown; +} + +interface SummaryFilter { + startDate?: unknown; + endDate?: unknown; +} + +function requiredNonNegativeInteger(value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new ValidationError(); + } + return value; +} + +function validateTotals(data: ClassAttendanceInput) { + const totalEnrolled = requiredNonNegativeInteger(data.total_enrolled); + const totalPresent = requiredNonNegativeInteger(data.total_present); + const totalAbsent = requiredNonNegativeInteger(data.total_absent); + const totalTardy = requiredNonNegativeInteger(data.total_tardy ?? 0); + + if ( + totalEnrolled <= 0 || + totalPresent > totalEnrolled || + totalAbsent > totalEnrolled || + totalTardy > totalEnrolled + ) { + throw new ValidationError(); + } + + return { + total_enrolled: totalEnrolled, + total_present: totalPresent, + total_absent: totalAbsent, + total_tardy: totalTardy, + attendance_percentage: ((totalPresent / totalEnrolled) * 100).toFixed(2), + }; +} + +/** Subtree rollup filter for the active tier, over the denormalized columns. */ +function rollupScope(currentUser?: CurrentUser): Record { + if (hasGlobalAccess(currentUser)) { + return {}; + } + const organizationId = getOrganizationId(currentUser); + const base = organizationId ? { organizationId } : {}; + switch (getRoleScope(currentUser)) { + case ROLE_SCOPES.SCHOOL: + return { ...base, schoolId: getSchoolId(currentUser) }; + case ROLE_SCOPES.CAMPUS: + return { ...base, campusId: getCampusId(currentUser) }; + case ROLE_SCOPES.CLASS: + return { ...base, classId: getClassId(currentUser) }; + default: + return base; + } +} + +/** Asserts the user may record attendance for `classData`'s tenant chain. */ +function assertCanRecord( + classData: { organizationId: string | null; campusId: string | null; schoolId: string | null; id: string }, + currentUser?: CurrentUser, +): void { + if (hasGlobalAccess(currentUser)) { + return; + } + const organizationId = getOrganizationId(currentUser); + if (organizationId && organizationId !== classData.organizationId) { + throw new ForbiddenError(); + } + switch (getRoleScope(currentUser)) { + case ROLE_SCOPES.ORGANIZATION: + return; + case ROLE_SCOPES.SCHOOL: + if (getSchoolId(currentUser) === classData.schoolId) return; + break; + case ROLE_SCOPES.CAMPUS: + if (getCampusId(currentUser) === classData.campusId) return; + break; + case ROLE_SCOPES.CLASS: + if (getClassId(currentUser) === classData.id) return; + break; + default: + break; + } + throw new ForbiddenError(); +} + +function dateRange(filter: SummaryFilter): Record { + const start = filter.startDate ? requiredIsoDate(filter.startDate) : null; + const end = filter.endDate ? requiredIsoDate(filter.endDate) : null; + if (!start && !end) return {}; + return { + attendance_date: { + ...(start ? { [Op.gte]: start } : {}), + ...(end ? { [Op.lte]: end } : {}), + }, + }; +} + +function toDto(record: ClassAttendance) { + const plain = record.get({ plain: true }); + return { + id: plain.id, + classId: plain.classId, + date: plain.attendance_date, + total_enrolled: plain.total_enrolled, + total_present: plain.total_present, + total_absent: plain.total_absent, + total_tardy: plain.total_tardy, + attendance_percentage: Number(plain.attendance_percentage), + recorded_by_label: plain.recorded_by_label, + organizationId: plain.organizationId, + schoolId: plain.schoolId, + campusId: plain.campusId, + }; +} + +class ClassAttendanceService { + /** Teacher (or a higher tier drilling in) records a class's daily aggregate. */ + static async upsert( + classIdParam: unknown, + dateParam: unknown, + data: ClassAttendanceInput, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + const classId = typeof classIdParam === 'string' ? classIdParam : ''; + if (!classId) { + throw new ValidationError(); + } + const attendanceDate = requiredIsoDate(dateParam); + + const cls = await db.classes.findByPk(classId); + if (!cls) { + throw new ValidationError(); + } + const campus = cls.campusId + ? await db.campuses.findByPk(cls.campusId) + : null; + const classData = { + id: cls.id, + organizationId: cls.organizationId ?? null, + campusId: cls.campusId ?? null, + schoolId: campus?.schoolId ?? null, + }; + + assertCanRecord(classData, currentUser); + + const totals = validateTotals(data); + const payload = { + ...totals, + classId, + attendance_date: attendanceDate, + recorded_by_label: getDisplayName(currentUser), + organizationId: classData.organizationId, + schoolId: classData.schoolId, + campusId: classData.campusId, + updatedById: currentUser?.id ?? null, + }; + + return withTransaction(async (transaction) => { + const existing = await db.class_attendance.findOne({ + where: { classId, attendance_date: attendanceDate }, + transaction, + }); + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.class_attendance.create( + { ...payload, createdById: requireUserId(currentUser) }, + { transaction }, + ); + return toDto(saved); + }); + } + + /** Per-date roll-up totals for the active tier (its whole subtree summed). */ + static async summary(filter: SummaryFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + + const rows = await db.class_attendance.findAll({ + attributes: [ + 'attendance_date', + [fn('SUM', col('total_enrolled')), 'total_enrolled'], + [fn('SUM', col('total_present')), 'total_present'], + [fn('SUM', col('total_absent')), 'total_absent'], + [fn('SUM', col('total_tardy')), 'total_tardy'], + ], + where: { ...rollupScope(currentUser), ...dateRange(filter) }, + group: ['attendance_date'], + order: [['attendance_date', 'DESC']], + }); + + return { + rows: rows.map((row) => { + const plain = row.get({ plain: true }) as Record; + const enrolled = Number(plain.total_enrolled) || 0; + const present = Number(plain.total_present) || 0; + return { + date: plain.attendance_date, + total_enrolled: enrolled, + total_present: present, + total_absent: Number(plain.total_absent) || 0, + total_tardy: Number(plain.total_tardy) || 0, + attendance_percentage: + enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : 0, + }; + }), + }; + } +} + +export default ClassAttendanceService; diff --git a/backend/src/services/communications.test.ts b/backend/src/services/communications.test.ts new file mode 100644 index 0000000..eb66897 --- /dev/null +++ b/backend/src/services/communications.test.ts @@ -0,0 +1,510 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Op } from 'sequelize'; + +import db from '@/db/models'; +import CommunicationsService from '@/services/communications'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_SCOPES } from '@/shared/constants/roles'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { createGlobalAccessUser, createTestUser } from '@/test-utils'; + +function permission(name: string) { + return { name }; +} + +function assertScopeArray(value: unknown): asserts value is Array> { + assert.ok(Array.isArray(value)); +} + +function eventRecord(overrides: Record = {}) { + const data = { + id: 'event-1', + title: 'Alert', + event_date: '2026-06-15', + event_type: 'event', + targetLevel: 'campus', + roles: ['director'], + organizationId: 'org-1', + campusId: 'campus-1', + schoolId: null, + classId: null, + canceledEventId: null, + createdById: 'user-1', + updatedById: 'user-1', + createdAt: new Date('2026-06-15T00:00:00Z'), + updatedAt: new Date('2026-06-15T00:00:00Z'), + ...overrides, + }; + return { + update: async (payload: Record) => { + Object.assign(data, payload); + return eventRecord(data); + }, + destroy: async () => undefined, + get roles() { + return data.roles; + }, + get: () => ({ + ...data, + }), + }; +} + +function managerUser(overrides: Parameters[0] = {}) { + return createTestUser({ + id: 'manager-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + app_role: { + name: 'director', + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM)], + }, + campusId: 'campus-1', + ...overrides, + }); +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('CommunicationsService event targets', () => { + test('creates system-only platform alerts for global managers by default', async () => { + const createPayloads: Record[] = []; + mock.method(db.communication_events, 'create', (async (payload: Record) => { + createPayloads.push(payload); + return eventRecord(payload); + }) as typeof db.communication_events.create); + + await CommunicationsService.createEvent( + { title: 'Global alert', date: '2026-06-15', type: 'event' }, + createGlobalAccessUser({ id: 'admin-1' }), + ); + + assert.equal(createPayloads[0].targetLevel, 'system'); + assert.equal(createPayloads[0].organizationId, null); + assert.equal(createPayloads[0].schoolId, null); + assert.equal(createPayloads[0].campusId, null); + assert.deepEqual(createPayloads[0].roles, ['super_admin', 'system_admin']); + }); + + test('creates one exact target row per selected organization', async () => { + const createPayloads: Record[] = []; + mock.method(db.organizations, 'findByPk', (async () => ({ id: 'org-1' })) as typeof db.organizations.findByPk); + mock.method(db.communication_events, 'create', (async (payload: Record) => { + createPayloads.push(payload); + return eventRecord(payload); + }) as typeof db.communication_events.create); + + await CommunicationsService.createEvent( + { + title: 'Org alert', + date: '2026-06-15', + type: 'event', + targets: [{ level: 'organization', id: 'org-1' }], + }, + createGlobalAccessUser({ id: 'admin-1' }), + ); + + assert.equal(createPayloads[0].targetLevel, 'organization'); + assert.equal(createPayloads[0].organizationId, 'org-1'); + assert.equal(createPayloads[0].schoolId, null); + assert.equal(createPayloads[0].campusId, null); + assert.deepEqual(createPayloads[0].roles, ['owner', 'superintendent']); + }); + + test('creates school and campus rows when an organization manager selects both levels', async () => { + const createPayloads: Record[] = []; + mock.method(db.schools, 'findByPk', (async () => ({ id: 'school-1', organizationId: 'org-1' })) as typeof db.schools.findByPk); + mock.method(db.campuses, 'findByPk', (async () => ({ + id: 'campus-1', + organizationId: 'org-1', + schoolId: 'school-1', + })) as typeof db.campuses.findByPk); + mock.method(db.communication_events, 'create', (async (payload: Record) => { + createPayloads.push(payload); + return eventRecord(payload); + }) as typeof db.communication_events.create); + + await CommunicationsService.createEvent( + { + title: 'School and campus alert', + date: '2026-06-15', + type: 'event', + targets: [ + { level: 'school', id: 'school-1' }, + { level: 'campus', id: 'campus-1' }, + ], + }, + managerUser({ + app_role: { + name: 'owner', + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM)], + }, + }), + ); + + assert.equal(createPayloads.length, 2); + assert.equal(createPayloads[0].targetLevel, 'school'); + assert.deepEqual(createPayloads[0].roles, ['principal', 'registrar']); + assert.equal(createPayloads[1].targetLevel, 'campus'); + assert.deepEqual(createPayloads[1].roles, ['director', 'office_manager', 'teacher', 'support_staff']); + }); + + test('rejects alert creation without MANAGE_INTERNAL_COMM for tenant users', async () => { + await assert.rejects( + () => CommunicationsService.createEvent( + { title: 'Read-only alert', date: '2026-06-15', type: 'event' }, + managerUser({ + app_role: { + name: 'office_manager', + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [], + }, + }), + ), + ForbiddenError, + ); + }); + + test('creates campus alerts for campus managers only inside their own campus', async () => { + const createPayloads: Record[] = []; + mock.method(db.campuses, 'findByPk', (async () => ({ + id: 'campus-1', + organizationId: 'org-1', + schoolId: 'school-1', + })) as typeof db.campuses.findByPk); + mock.method(db.communication_events, 'create', (async (payload: Record) => { + createPayloads.push(payload); + return eventRecord(payload); + }) as typeof db.communication_events.create); + + await CommunicationsService.createEvent( + { + title: 'Campus alert', + date: '2026-06-15', + type: 'event', + targets: [{ level: 'campus', id: 'campus-1' }], + }, + managerUser(), + ); + + assert.equal(createPayloads[0].targetLevel, 'campus'); + assert.equal(createPayloads[0].organizationId, 'org-1'); + assert.equal(createPayloads[0].schoolId, 'school-1'); + assert.equal(createPayloads[0].campusId, 'campus-1'); + }); + + test('rejects campus manager attempts to create school-level alerts', async () => { + await assert.rejects( + () => CommunicationsService.createEvent( + { + title: 'School alert', + date: '2026-06-15', + type: 'event', + targets: [{ level: 'school', id: 'school-1' }], + }, + managerUser(), + ), + ForbiddenError, + ); + }); + + test('rejects school manager attempts to create campus alerts outside their school', async () => { + mock.method(db.campuses, 'findByPk', (async () => ({ + id: 'campus-2', + organizationId: 'org-1', + schoolId: 'school-2', + })) as typeof db.campuses.findByPk); + + await assert.rejects( + () => CommunicationsService.createEvent( + { + title: 'Wrong campus alert', + date: '2026-06-15', + type: 'event', + targets: [{ level: 'campus', id: 'campus-2' }], + }, + managerUser({ + schoolId: 'school-1', + app_role: { + name: 'principal', + scope: ROLE_SCOPES.SCHOOL, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM)], + }, + }), + ), + ForbiddenError, + ); + }); + + test('updates alerts for the creator', async () => { + const event = eventRecord({ createdById: 'manager-1' }); + mock.method(db.communication_events, 'findByPk', (async () => event) as typeof db.communication_events.findByPk); + + const updated = await CommunicationsService.updateEvent( + 'event-1', + { title: 'Updated alert', date: '2026-06-20', type: 'deadline' }, + managerUser(), + ); + + assert.equal(updated.title, 'Updated alert'); + assert.equal(updated.date, '2026-06-20'); + assert.equal(updated.type, 'deadline'); + assert.equal(updated.updatedById, 'manager-1'); + }); + + test('rejects alert updates by non-creator users without manager scope', async () => { + mock.method( + db.communication_events, + 'findByPk', + (async () => eventRecord({ createdById: 'other-user', campusId: 'other-campus' })) as typeof db.communication_events.findByPk, + ); + + await assert.rejects( + () => CommunicationsService.updateEvent( + 'event-1', + { title: 'Rejected update' }, + managerUser(), + ), + ForbiddenError, + ); + }); + + test('deletes alerts for scoped managers', async () => { + let deleted = false; + const event = { + ...eventRecord({ createdById: 'other-user', campusId: 'campus-1' }), + destroy: async () => { + deleted = true; + }, + }; + mock.method(db.communication_events, 'findByPk', (async () => event) as typeof db.communication_events.findByPk); + + await CommunicationsService.deleteEvent('event-1', managerUser()); + + assert.equal(deleted, true); + }); + + test('canceling an alert creates a cancellation notification and deletes the original', async () => { + let deleted = false; + const createPayloads: Record[] = []; + const event = { + ...eventRecord({ createdById: 'manager-1', title: 'Team meeting' }), + destroy: async () => { + deleted = true; + }, + }; + mock.method(db.communication_events, 'findByPk', (async () => event) as typeof db.communication_events.findByPk); + mock.method(db.communication_events, 'create', (async (payload: Record) => { + createPayloads.push(payload); + return eventRecord(payload); + }) as typeof db.communication_events.create); + + const cancellation = await CommunicationsService.cancelEvent( + 'event-1', + { reason: 'weather' }, + managerUser(), + ); + + assert.equal(deleted, true); + assert.equal(createPayloads[0].title, 'Canceled: Team meeting - weather'); + assert.equal(createPayloads[0].canceledEventId, 'event-1'); + assert.equal(createPayloads[0].targetLevel, 'campus'); + assert.equal(cancellation.canceledEventId, 'event-1'); + }); + + test('rejects mutating cancellation notifications', async () => { + mock.method( + db.communication_events, + 'findByPk', + (async () => eventRecord({ canceledEventId: 'original-event-1', createdById: 'manager-1' })) as typeof db.communication_events.findByPk, + ); + + await assert.rejects( + () => CommunicationsService.updateEvent('event-1', { title: 'Nope' }, managerUser()), + ForbiddenError, + ); + await assert.rejects( + () => CommunicationsService.deleteEvent('event-1', managerUser()), + ForbiddenError, + ); + await assert.rejects( + () => CommunicationsService.cancelEvent('event-1', {}, managerUser()), + ForbiddenError, + ); + }); + + test('lists platform alerts and own tenant-target alerts for global root users', async () => { + let listScopes: Array> = []; + mock.method( + db.communication_events, + 'findAndCountAll', + (async (query: { where: Record }) => { + const scopes = query.where[Op.or]; + assertScopeArray(scopes); + listScopes = scopes; + return { rows: [eventRecord()], count: 1 }; + }) as typeof db.communication_events.findAndCountAll, + ); + + await CommunicationsService.listEvents({}, createGlobalAccessUser({ id: 'admin-1' })); + + assert.equal(listScopes.length, 5); + assert.deepEqual( + listScopes.map((scope) => scope.targetLevel), + ['all', 'system', 'organization', 'school', 'campus'], + ); + assert.equal(listScopes[2].createdById, 'admin-1'); + assert.equal(listScopes[3].createdById, 'admin-1'); + assert.equal(listScopes[4].createdById, 'admin-1'); + }); + + test('lists tenant alerts for global users only when drilled into that tenant scope', async () => { + let listScopes: Array> = []; + mock.method( + db.communication_events, + 'findAndCountAll', + (async (query: { where: Record }) => { + const scopes = query.where[Op.or]; + assertScopeArray(scopes); + listScopes = scopes; + return { rows: [eventRecord()], count: 1 }; + }) as typeof db.communication_events.findAndCountAll, + ); + + await CommunicationsService.listEvents({}, createGlobalAccessUser({ + id: 'admin-1', + activeScope: { + level: 'organization', + organizationId: 'org-1', + schoolId: null, + campusId: null, + classId: null, + }, + })); + + assert.equal(listScopes.length, 4); + assert.deepEqual( + listScopes.map((scope) => scope.targetLevel), + ['all', 'organization', 'school', 'campus'], + ); + assert.equal(listScopes[1].organizationId, 'org-1'); + assert.equal(listScopes[2].organizationId, 'org-1'); + assert.equal(listScopes[3].organizationId, 'org-1'); + }); + + test('lists descendant school and campus target alerts for organization-scope users', async () => { + let listScopes: Array> = []; + mock.method( + db.communication_events, + 'findAndCountAll', + (async (query: { where: Record }) => { + const scopes = query.where[Op.or]; + assertScopeArray(scopes); + listScopes = scopes; + return { rows: [eventRecord()], count: 1 }; + }) as typeof db.communication_events.findAndCountAll, + ); + + await CommunicationsService.listEvents({}, managerUser({ + app_role: { + name: 'owner', + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + permissions: [], + }, + })); + + assert.equal(listScopes.length, 4); + assert.deepEqual( + listScopes.map((scope) => scope.targetLevel), + ['all', 'organization', 'school', 'campus'], + ); + assert.equal(listScopes[2].organizationId, 'org-1'); + assert.equal(listScopes[3].organizationId, 'org-1'); + }); + + test('lists descendant campus target alerts for school-scope users', async () => { + let listScopes: Array> = []; + mock.method( + db.communication_events, + 'findAndCountAll', + (async (query: { where: Record }) => { + const scopes = query.where[Op.or]; + assertScopeArray(scopes); + listScopes = scopes; + return { rows: [eventRecord()], count: 1 }; + }) as typeof db.communication_events.findAndCountAll, + ); + + await CommunicationsService.listEvents({}, managerUser({ + schoolId: 'school-1', + app_role: { + name: 'principal', + scope: ROLE_SCOPES.SCHOOL, + globalAccess: false, + permissions: [], + }, + })); + + assert.equal(listScopes.length, 3); + assert.deepEqual( + listScopes.map((scope) => scope.targetLevel), + ['all', 'school', 'campus'], + ); + assert.equal(listScopes[2].schoolId, 'school-1'); + }); + + test('lists campus target alerts for class-scope users', async () => { + let listScopes: Array> = []; + mock.method( + db.communication_events, + 'findAndCountAll', + (async (query: { where: Record }) => { + const scopes = query.where[Op.or]; + assertScopeArray(scopes); + listScopes = scopes; + return { rows: [eventRecord()], count: 1 }; + }) as typeof db.communication_events.findAndCountAll, + ); + + await CommunicationsService.listEvents({}, managerUser({ + campusId: 'campus-1', + app_role: { + name: 'teacher', + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [], + }, + })); + + assert.equal(listScopes.length, 2); + assert.equal(listScopes[1].targetLevel, 'campus'); + assert.equal(listScopes[1].campusId, 'campus-1'); + }); + + test('rejects alert creation from class scope', async () => { + await assert.rejects( + () => CommunicationsService.createEvent( + { title: 'Class alert', date: '2026-06-15', type: 'event' }, + managerUser({ + app_role: { + name: 'teacher', + scope: ROLE_SCOPES.CLASS, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM)], + }, + }), + ), + ForbiddenError, + ); + }); +}); diff --git a/backend/src/services/communications.ts b/backend/src/services/communications.ts index b890aab..e3e5504 100644 --- a/backend/src/services/communications.ts +++ b/backend/src/services/communications.ts @@ -1,51 +1,83 @@ -import { nullableString } from '@/services/shared/validate'; -import { isRecord } from '@/shared/object'; +import { Op } from 'sequelize'; import db from '@/db/models'; -import { withTransaction } from '@/db/with-transaction'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; import { assertAuthenticatedTenantUser, - campusDimensionScope, + hasFeaturePermission, + hasGlobalAccess, getCampusId, getOrganizationId, - getOrganizationIdOrGlobal, - hasGlobalAccess, - hasRoleAccess, - requireOrganizationId, + getRoleScope, + getSchoolId, requireUserId, } from '@/services/shared/access'; import { - COMMUNICATION_AUDIENCES, - COMMUNICATION_CHANNELS, COMMUNICATION_EVENT_TYPE_VALUES, type CommunicationEventType, - COMMUNICATION_MANAGER_ROLE_NAMES, - COMMUNICATION_RECIPIENT_TYPES, - COMMUNICATION_STATUSES, - DEFAULT_PARENT_MESSAGE_CATEGORY, - PARENT_MESSAGE_CATEGORY_VALUES, - type ParentMessageCategory, } from '@/shared/constants/communications'; -import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_NAMES, ROLE_SCOPES, type RoleName } from '@/shared/constants/roles'; import { resolvePagination } from '@/shared/constants/pagination'; -import type { Messages } from '@/db/models/messages'; import type { CommunicationEvents } from '@/db/models/communication_events'; import type { CurrentUser } from '@/db/api/types'; import type { - ParentMessageFilter, - ParentMessageInput, + EventCancelInput, EventFilter, EventInput, + EventUpdateInput, } from '@/services/communications.types'; -const DEFAULT_EVENT_ROLES: RoleName[] = [ +const SYSTEM_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, +]; +const ORGANIZATION_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, +]; +const SCHOOL_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, +]; +const CAMPUS_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, - ROLE_NAMES.OFFICE_MANAGER, +]; +const ALL_USER_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, + ROLE_NAMES.STUDENT, + ROLE_NAMES.GUARDIAN, ]; const ROLE_NAME_LIST: readonly RoleName[] = Object.values(ROLE_NAMES); +const TARGET_LEVELS = ['system', 'all', 'organization', 'school', 'campus'] as const; + +type CommunicationEventTargetLevel = (typeof TARGET_LEVELS)[number]; + +interface CommunicationEventTarget { + level: CommunicationEventTargetLevel; + id: string | null; +} + +interface CommunicationEventStamp { + targetLevel: CommunicationEventTargetLevel; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: null; + roles: RoleName[]; +} function requireEventType(value: unknown): CommunicationEventType { const type = requiredString(value); @@ -59,7 +91,14 @@ function requireEventType(value: unknown): CommunicationEventType { function assertCanManageCommunications(currentUser?: CurrentUser): void { assertAuthenticatedTenantUser(currentUser); - if (hasRoleAccess(currentUser, COMMUNICATION_MANAGER_ROLE_NAMES)) { + if ( + currentUser?.app_role?.globalAccess === true + || + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM, + ) + ) { return; } @@ -74,9 +113,9 @@ function requiredString(value: unknown): string { return value.trim(); } -function validateRoles(roles: unknown): RoleName[] { +function validateRoles(roles: unknown, defaultRoles: readonly RoleName[]): RoleName[] { if (!Array.isArray(roles) || roles.length === 0) { - return [...DEFAULT_EVENT_ROLES]; + return [...defaultRoles]; } return roles.map((role: unknown) => { @@ -89,44 +128,287 @@ function validateRoles(roles: unknown): RoleName[] { }); } -function toParentMessageCategory(subject: unknown): ParentMessageCategory { - const match = PARENT_MESSAGE_CATEGORY_VALUES.find((item) => item === subject); - return match ?? DEFAULT_PARENT_MESSAGE_CATEGORY; +function targetDefaultRoles(level: CommunicationEventTargetLevel): readonly RoleName[] { + if (level === 'system') return SYSTEM_EVENT_ROLES; + if (level === 'all') return ALL_USER_EVENT_ROLES; + if (level === 'organization') return ORGANIZATION_EVENT_ROLES; + if (level === 'school') return SCHOOL_EVENT_ROLES; + return CAMPUS_EVENT_ROLES; } -function toIsoString(value: Date | string | null): string { - return new Date(value ?? Date.now()).toISOString(); +function isTargetLevel(value: unknown): value is CommunicationEventTargetLevel { + return typeof value === 'string' && TARGET_LEVELS.some((level) => level === value); } -function toParentMessageDto(message: Messages | null) { - if (!message) { - return null; +function optionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function optionalTrimmedString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function requireTargetId(target: CommunicationEventTarget): string { + if (!target.id) { + throw new ValidationError(); + } + return target.id; +} + +function normalizeTargets(value: unknown, currentUser?: CurrentUser): CommunicationEventTarget[] { + if (Array.isArray(value) && value.length > 0) { + return value.map((raw) => { + if (!raw || typeof raw !== 'object' || !('level' in raw) || !isTargetLevel(raw.level)) { + throw new ValidationError(); + } + const id = 'id' in raw ? optionalString(raw.id) : null; + if ((raw.level === 'system' || raw.level === 'all') && id) { + throw new ValidationError(); + } + if ((raw.level === 'organization' || raw.level === 'school' || raw.level === 'campus') && !id) { + throw new ValidationError(); + } + return { level: raw.level, id }; + }); } - const plain = message.get({ plain: true }); - const recipients = message.get('message_recipients_message'); - const firstRecipient = Array.isArray(recipients) ? recipients[0] : null; - const recipientLabel = - isRecord(firstRecipient) && typeof firstRecipient.recipient_label === 'string' - ? firstRecipient.recipient_label - : ''; + if (hasGlobalAccess(currentUser)) { + return [{ level: 'system', id: null }]; + } + const scope = getRoleScope(currentUser); + if (scope === ROLE_SCOPES.ORGANIZATION) { + return [{ level: 'organization', id: getOrganizationId(currentUser) }]; + } + if (scope === ROLE_SCOPES.SCHOOL) { + return [{ level: 'school', id: getSchoolId(currentUser) }]; + } + if (scope === ROLE_SCOPES.CAMPUS) { + return [{ level: 'campus', id: getCampusId(currentUser) }]; + } + + throw new ForbiddenError(); +} + +async function resolveTarget( + target: CommunicationEventTarget, + currentUser?: CurrentUser, +): Promise { + const scope = getRoleScope(currentUser); + + if (target.level === 'system' || target.level === 'all') { + if (!hasGlobalAccess(currentUser)) { + throw new ForbiddenError(); + } + return { + targetLevel: target.level, + organizationId: null, + schoolId: null, + campusId: null, + classId: null, + roles: [...targetDefaultRoles(target.level)], + }; + } + + if (target.level === 'organization') { + if ( + !hasGlobalAccess(currentUser) + && scope !== ROLE_SCOPES.SYSTEM + && scope !== ROLE_SCOPES.ORGANIZATION + ) { + throw new ForbiddenError(); + } + const organization = await db.organizations.findByPk(requireTargetId(target), { attributes: ['id'] }); + if (!organization) throw new ForbiddenError(); + if (!hasGlobalAccess(currentUser) && organization.id !== getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } + return { + targetLevel: 'organization', + organizationId: organization.id, + schoolId: null, + campusId: null, + classId: null, + roles: [...ORGANIZATION_EVENT_ROLES], + }; + } + + if (target.level === 'school') { + if ( + !hasGlobalAccess(currentUser) + && + scope !== ROLE_SCOPES.SYSTEM + && scope !== ROLE_SCOPES.ORGANIZATION + && scope !== ROLE_SCOPES.SCHOOL + ) { + throw new ForbiddenError(); + } + const school = await db.schools.findByPk(requireTargetId(target), { attributes: ['id', 'organizationId'] }); + if (!school) throw new ForbiddenError(); + if (!hasGlobalAccess(currentUser)) { + if (scope === ROLE_SCOPES.ORGANIZATION && school.organizationId !== getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } + if (scope === ROLE_SCOPES.SCHOOL && school.id !== getSchoolId(currentUser)) { + throw new ForbiddenError(); + } + } + return { + targetLevel: 'school', + organizationId: school.organizationId, + schoolId: school.id, + campusId: null, + classId: null, + roles: [...SCHOOL_EVENT_ROLES], + }; + } + + if (scope === ROLE_SCOPES.CLASS) { + throw new ForbiddenError(); + } + const campus = await db.campuses.findByPk(requireTargetId(target), { + attributes: ['id', 'organizationId', 'schoolId'], + }); + if (!campus) throw new ForbiddenError(); + if (!hasGlobalAccess(currentUser)) { + if (scope === ROLE_SCOPES.ORGANIZATION && campus.organizationId !== getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } + if (scope === ROLE_SCOPES.SCHOOL && campus.schoolId !== getSchoolId(currentUser)) { + throw new ForbiddenError(); + } + if (scope === ROLE_SCOPES.CAMPUS && campus.id !== getCampusId(currentUser)) { + throw new ForbiddenError(); + } + } return { - id: plain.id, - text: plain.body ?? '', - to: recipientLabel, - date: toIsoString(plain.sent_at ?? plain.createdAt), - category: toParentMessageCategory(plain.subject), - sentAt: toIsoString(plain.sent_at ?? plain.createdAt), - organizationId: plain.organizationId, - campusId: plain.campusId, - createdById: plain.createdById, - updatedById: plain.updatedById, - createdAt: plain.createdAt, - updatedAt: plain.updatedAt, + targetLevel: 'campus', + organizationId: campus.organizationId, + schoolId: campus.schoolId, + campusId: campus.id, + classId: null, + roles: [...CAMPUS_EVENT_ROLES], }; } +function communicationEventWhere(currentUser?: CurrentUser) { + const allUsers = { + targetLevel: 'all', + organizationId: null, + schoolId: null, + campusId: null, + classId: null, + }; + + if (hasGlobalAccess(currentUser)) { + const createdById = requireUserId(currentUser); + return { + [Op.or]: [ + allUsers, + { + targetLevel: 'system', + organizationId: null, + schoolId: null, + campusId: null, + classId: null, + }, + { targetLevel: 'organization', createdById }, + { targetLevel: 'school', createdById }, + { targetLevel: 'campus', createdById }, + ], + }; + } + + const scope = getRoleScope(currentUser); + const organizationId = getOrganizationId(currentUser); + const schoolId = getSchoolId(currentUser); + const campusId = getCampusId(currentUser); + + if (scope === ROLE_SCOPES.ORGANIZATION) { + return { + [Op.or]: [ + allUsers, + { targetLevel: 'organization', organizationId, schoolId: null, campusId: null, classId: null }, + { targetLevel: 'school', organizationId, campusId: null, classId: null }, + { targetLevel: 'campus', organizationId, classId: null }, + ], + }; + } + if (scope === ROLE_SCOPES.SCHOOL) { + return { + [Op.or]: [ + allUsers, + { targetLevel: 'school', organizationId, schoolId, campusId: null, classId: null }, + { targetLevel: 'campus', organizationId, schoolId, classId: null }, + ], + }; + } + if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) { + return { + [Op.or]: [ + allUsers, + { targetLevel: 'campus', organizationId, campusId, classId: null }, + ], + }; + } + + return { [Op.or]: [allUsers] }; +} + +function eventTenantMatchesManagerScope( + event: CommunicationEvents, + currentUser?: CurrentUser, +): boolean { + if (hasGlobalAccess(currentUser)) { + return true; + } + if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM)) { + return false; + } + const scope = getRoleScope(currentUser); + const plain = event.get({ plain: true }); + + if (plain.targetLevel === 'system' || plain.targetLevel === 'all') { + return false; + } + if (scope === ROLE_SCOPES.ORGANIZATION) { + return plain.organizationId === getOrganizationId(currentUser); + } + if (scope === ROLE_SCOPES.SCHOOL) { + return ( + (plain.targetLevel === 'school' || plain.targetLevel === 'campus') + && plain.organizationId === getOrganizationId(currentUser) + && plain.schoolId === getSchoolId(currentUser) + ); + } + if (scope === ROLE_SCOPES.CAMPUS) { + return ( + plain.targetLevel === 'campus' + && plain.organizationId === getOrganizationId(currentUser) + && plain.campusId === getCampusId(currentUser) + ); + } + + return false; +} + +async function findMutableEvent(id: string, currentUser?: CurrentUser): Promise { + assertAuthenticatedTenantUser(currentUser); + const event = await db.communication_events.findByPk(requiredString(id)); + if (!event) { + throw new ForbiddenError(); + } + const plain = event.get({ plain: true }); + if (plain.canceledEventId) { + throw new ForbiddenError(); + } + if (plain.createdById === currentUser?.id || eventTenantMatchesManagerScope(event, currentUser)) { + return event; + } + + throw new ForbiddenError(); +} + function toCommunicationEventDto(event: CommunicationEvents) { const plain = event.get({ plain: true }); @@ -135,9 +417,13 @@ function toCommunicationEventDto(event: CommunicationEvents) { title: plain.title, date: plain.event_date, type: plain.event_type, + targetLevel: plain.targetLevel, roles: plain.roles, organizationId: plain.organizationId, campusId: plain.campusId, + schoolId: plain.schoolId, + classId: plain.classId, + canceledEventId: plain.canceledEventId, createdById: plain.createdById, updatedById: plain.updatedById, createdAt: plain.createdAt, @@ -146,119 +432,13 @@ function toCommunicationEventDto(event: CommunicationEvents) { } class CommunicationsService { - static async listParentMessages( - filter: ParentMessageFilter, - currentUser?: CurrentUser, - ) { - assertAuthenticatedTenantUser(currentUser); - const { limit, offset } = resolvePagination(filter.limit, filter.page); - - const organizationId = getOrganizationIdOrGlobal(currentUser); - const orgFilter = organizationId ? { organizationId } : {}; - const createdByFilter = hasGlobalAccess(currentUser) - ? {} - : { createdById: currentUser?.id ?? null }; - - const result = await db.messages.findAndCountAll({ - where: { - ...orgFilter, - ...createdByFilter, - audience: COMMUNICATION_AUDIENCES.GUARDIANS, - ...campusDimensionScope(currentUser), - ...(filter.category ? { subject: filter.category } : {}), - }, - include: [ - { - model: db.message_recipients, - as: 'message_recipients_message', - attributes: ['recipient_label'], - }, - ], - order: [ - ['sent_at', 'desc'], - ['createdAt', 'desc'], - ], - limit, - offset, - }); - - return { - rows: result.rows.map(toParentMessageDto), - count: result.count, - }; - } - - static async createParentMessage( - data: ParentMessageInput, - currentUser?: CurrentUser, - ) { - assertAuthenticatedTenantUser(currentUser); - - const recipientName = requiredString(data?.recipientName); - const messageText = requiredString(data?.messageText); - const category = toParentMessageCategory(nullableString(data?.category)); - - const createdMessage = await withTransaction(async (transaction) => { - const message = await db.messages.create( - { - subject: category, - body: messageText, - channel: COMMUNICATION_CHANNELS.IN_APP, - audience: COMMUNICATION_AUDIENCES.GUARDIANS, - sent_at: new Date(), - status: COMMUNICATION_STATUSES.SENT, - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - sent_byId: currentUser?.id ?? null, - createdById: currentUser?.id ?? null, - updatedById: currentUser?.id ?? null, - }, - { transaction }, - ); - - await db.message_recipients.create( - { - recipient_type: COMMUNICATION_RECIPIENT_TYPES.GUARDIAN, - recipient_label: recipientName, - destination: null, - delivery_status: COMMUNICATION_STATUSES.SENT, - delivered_at: new Date(), - read_at: null, - organizationId: getOrganizationId(currentUser), - messageId: message.id, - createdById: currentUser?.id ?? null, - updatedById: currentUser?.id ?? null, - }, - { transaction }, - ); - - return message; - }); - - const savedMessage = await db.messages.findByPk(createdMessage.id, { - include: [ - { - model: db.message_recipients, - as: 'message_recipients_message', - attributes: ['recipient_label'], - }, - ], - }); - - return toParentMessageDto(savedMessage); - } - static async listEvents(filter: EventFilter, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); - const organizationId = getOrganizationIdOrGlobal(currentUser); - const orgFilter = organizationId ? { organizationId } : {}; - const result = await db.communication_events.findAndCountAll({ where: { - ...orgFilter, - ...campusDimensionScope(currentUser), + ...communicationEventWhere(currentUser), ...(filter.type ? { event_type: filter.type } : {}), }, order: [ @@ -282,18 +462,90 @@ class CommunicationsService { const date = requiredString(data?.date); const type = requireEventType(data?.type); - const createdEvent = await db.communication_events.create({ + const targets = normalizeTargets(data?.targets, currentUser); + const stamps = await Promise.all(targets.map((target) => resolveTarget(target, currentUser))); + const createdEvents = await Promise.all(stamps.map((stamp) => db.communication_events.create({ title, event_date: date, event_type: type, - roles: validateRoles(data?.roles), - organizationId: requireOrganizationId(currentUser), - campusId: getCampusId(currentUser), + targetLevel: stamp.targetLevel, + roles: validateRoles(data?.roles, stamp.roles), + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: stamp.classId, + createdById: requireUserId(currentUser), + updatedById: currentUser?.id ?? null, + }))); + + return toCommunicationEventDto(createdEvents[0]); + } + + static async updateEvent(id: string, data: EventUpdateInput, currentUser?: CurrentUser) { + const event = await findMutableEvent(id, currentUser); + const update: Partial<{ + title: string; + event_date: string; + event_type: CommunicationEventType; + targetLevel: CommunicationEventTargetLevel; + roles: RoleName[]; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: null; + updatedById: string | null; + }> = { + updatedById: currentUser?.id ?? null, + }; + + if (data?.title !== undefined) update.title = requiredString(data.title); + if (data?.date !== undefined) update.event_date = requiredString(data.date); + if (data?.type !== undefined) update.event_type = requireEventType(data.type); + if (data?.roles !== undefined) update.roles = validateRoles(data.roles, event.roles); + if (data?.targets !== undefined) { + const targets = normalizeTargets(data.targets, currentUser); + if (targets.length !== 1) { + throw new ValidationError(); + } + const stamp = await resolveTarget(targets[0], currentUser); + update.targetLevel = stamp.targetLevel; + update.organizationId = stamp.organizationId; + update.schoolId = stamp.schoolId; + update.campusId = stamp.campusId; + update.classId = stamp.classId; + if (data?.roles === undefined) update.roles = [...stamp.roles]; + } + + await event.update(update); + return toCommunicationEventDto(event); + } + + static async deleteEvent(id: string, currentUser?: CurrentUser) { + const event = await findMutableEvent(id, currentUser); + await event.destroy(); + } + + static async cancelEvent(id: string, data: EventCancelInput, currentUser?: CurrentUser) { + const event = await findMutableEvent(id, currentUser); + const plain = event.get({ plain: true }); + const reason = optionalTrimmedString(data?.reason); + const cancellation = await db.communication_events.create({ + title: reason ? `Canceled: ${plain.title} - ${reason}` : `Canceled: ${plain.title}`, + event_date: plain.event_date, + event_type: plain.event_type, + targetLevel: plain.targetLevel, + roles: plain.roles, + organizationId: plain.organizationId, + schoolId: plain.schoolId, + campusId: plain.campusId, + classId: plain.classId, + canceledEventId: plain.id, createdById: requireUserId(currentUser), updatedById: currentUser?.id ?? null, }); + await event.destroy(); - return toCommunicationEventDto(createdEvent); + return toCommunicationEventDto(cancellation); } } diff --git a/backend/src/services/communications.types.ts b/backend/src/services/communications.types.ts index e9a1c81..e4cd272 100644 --- a/backend/src/services/communications.types.ts +++ b/backend/src/services/communications.types.ts @@ -1,15 +1,3 @@ -export interface ParentMessageFilter { - category?: string; - limit?: number | string; - page?: number | string; -} - -export interface ParentMessageInput { - recipientName?: unknown; - messageText?: unknown; - category?: unknown; -} - export interface EventFilter { type?: string; limit?: number | string; @@ -21,4 +9,11 @@ export interface EventInput { date?: unknown; type?: unknown; roles?: unknown; + targets?: unknown; +} + +export type EventUpdateInput = EventInput; + +export interface EventCancelInput { + reason?: unknown; } diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts new file mode 100644 index 0000000..25df7d1 --- /dev/null +++ b/backend/src/services/content_catalog.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import ContentCatalogService from '@/services/content_catalog'; +import { createGlobalAccessUser } from '@/test-utils'; + +function catalogRecord(payload: unknown) { + return { + get: () => ({ + id: 'catalog-1', + content_type: 'dashboard-encouraging-quotes', + payload, + updatedAt: new Date('2026-06-16T00:00:00Z'), + }), + }; +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('ContentCatalogService tenant scoping', () => { + test('reads dashboard content at an organization drill-down 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.findByType( + 'dashboard-encouraging-quotes', + createGlobalAccessUser({ + activeScope: { + level: 'organization', + organizationId: 'org-1', + schoolId: null, + campusId: null, + classId: null, + }, + }), + ); + + assert.deepEqual(capturedWhere, { + content_type: 'dashboard-encouraging-quotes', + active: true, + organizationId: 'org-1', + schoolId: null, + campusId: null, + classId: null, + }); + }); + + test('reads dashboard content at a school drill-down 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.findByType( + 'dashboard-encouraging-quotes', + createGlobalAccessUser({ + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + }), + ); + + assert.deepEqual(capturedWhere, { + content_type: 'dashboard-encouraging-quotes', + active: true, + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }); + }); + + test('reads dashboard content at a campus drill-down 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.findByType( + 'dashboard-encouraging-quotes', + createGlobalAccessUser({ + activeScope: { + level: 'campus', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + classId: null, + }, + }), + ); + + assert.deepEqual(capturedWhere, { + content_type: 'dashboard-encouraging-quotes', + active: true, + organizationId: 'org-1', + campusId: 'campus-1', + classId: null, + }); + }); +}); diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index 4b629df..d24bdc6 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -1,13 +1,70 @@ +import { Op } from 'sequelize'; import db from '@/db/models'; import { withTransaction } from '@/db/with-transaction'; import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; -import { hasRoleAccess } from '@/services/shared/access'; -import { CONTENT_CATALOG_MANAGER_ROLE_NAMES } from '@/shared/constants/content-catalog'; +import { + getOwnTenant, + getOrganizationId, + getSchoolId, + hasFeaturePermission, + tenantExactWhere, + tenantStamp, +} from '@/services/shared/access'; +import { + PER_TENANT_CONTENT_TYPES, + SCHOOL_SCOPED_CONTENT_TYPES, + ORG_SCOPED_CONTENT_TYPES, + TENANT_SCOPED_CONTENT_TYPES, +} from '@/shared/constants/content-catalog'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { ContentCatalog } from '@/db/models/content_catalog'; import type { CurrentUser } from '@/db/api/types'; +/** Scope where for tenant-scoped content types; `{}` for shared/global types. */ +function tenantWhereFor( + contentType: string, + currentUser?: CurrentUser, +): Record { + if (PER_TENANT_CONTENT_TYPES.has(contentType)) { + return tenantExactWhere(getOwnTenant(currentUser)); + } + if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { + // Everyone in the school reads their school's row (the user's school is + // resolved from their own school/campus chain). + return { schoolId: getSchoolId(currentUser) ?? null }; + } + if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) { + return { organizationId: getOrganizationId(currentUser) ?? null }; + } + return {}; +} + +/** Owning ids to stamp on a tenant-scoped content row; all null for shared. */ +function tenantStampFor(contentType: string, currentUser?: CurrentUser) { + if (PER_TENANT_CONTENT_TYPES.has(contentType)) { + return tenantStamp(getOwnTenant(currentUser)); + } + if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { + return { + organizationId: getOrganizationId(currentUser), + schoolId: getSchoolId(currentUser), + campusId: null, + classId: null, + }; + } + if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) { + return { + organizationId: getOrganizationId(currentUser), + schoolId: null, + campusId: null, + classId: null, + }; + } + return { organizationId: null, schoolId: null, campusId: null, classId: null }; +} + interface ContentCatalogInput { content_type?: unknown; payload?: unknown; @@ -27,13 +84,33 @@ function toContentCatalogDto(record: ContentCatalog) { } function assertCanManageContentCatalog(currentUser?: CurrentUser): void { - if (hasRoleAccess(currentUser, CONTENT_CATALOG_MANAGER_ROLE_NAMES)) { + if ( + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG, + ) + ) { return; } throw new ForbiddenError(); } +/** + * Edit authorization per content type. Tenant scoping constrains the row; the + * right to edit content catalog data is a single effective permission. + */ +function assertCanManageType(currentUser?: CurrentUser): void { + if ( + !hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG, + ) + ) { + throw new ForbiddenError(); + } +} + function assertValidContentType(contentType: unknown): string { if (typeof contentType !== 'string' || contentType.trim().length === 0) { throw new ValidationError(); @@ -59,7 +136,10 @@ class ContentCatalogService { const { limit, offset } = resolvePagination(filter.limit, filter.page); + // Per-tenant content types are managed via their dedicated editor (by type + + // tenant), so they are excluded from the shared management list. const result = await db.content_catalog.findAndCountAll({ + where: { content_type: { [Op.notIn]: [...TENANT_SCOPED_CONTENT_TYPES] } }, order: [['content_type', 'asc']], limit, offset, @@ -71,11 +151,19 @@ class ContentCatalogService { }; } - static async findByType(contentType: unknown) { + static async findByType(contentType: unknown, currentUser?: CurrentUser) { + const normalizedContentType = assertValidContentType(contentType); + + // Tenant-scoped content is never served on the unauthenticated public path. + if (TENANT_SCOPED_CONTENT_TYPES.has(normalizedContentType) && !currentUser?.id) { + throw new ValidationError('contentCatalogNotFound'); + } + const record = await db.content_catalog.findOne({ where: { - content_type: assertValidContentType(contentType), + content_type: normalizedContentType, active: true, + ...tenantWhereFor(normalizedContentType, currentUser), }, }); @@ -89,17 +177,19 @@ class ContentCatalogService { static async findManagedByType(contentType: unknown, currentUser?: CurrentUser) { assertCanManageContentCatalog(currentUser); - return this.findByType(contentType); + return this.findByType(contentType, currentUser); } static async create(data: ContentCatalogInput, currentUser?: CurrentUser) { - assertCanManageContentCatalog(currentUser); - const contentType = assertValidContentType(data?.content_type); + assertCanManageType(currentUser); + const payload = assertValidPayload(data?.payload); + const tenantWhere = tenantWhereFor(contentType, currentUser); + const stamp = tenantStampFor(contentType, currentUser); const existingRecord = await db.content_catalog.findOne({ - where: { content_type: contentType }, + where: { content_type: contentType, ...tenantWhere }, paranoid: false, }); @@ -126,6 +216,7 @@ class ContentCatalogService { payload, active: data.active !== false, importHash: data.importHash || null, + ...stamp, }, { transaction }, ); @@ -140,14 +231,17 @@ class ContentCatalogService { data: ContentCatalogInput, currentUser?: CurrentUser, ) { - assertCanManageContentCatalog(currentUser); - const normalizedContentType = assertValidContentType(contentType); + assertCanManageType(currentUser); + const payload = assertValidPayload(data?.payload); return withTransaction(async (transaction) => { const record = await db.content_catalog.findOne({ - where: { content_type: normalizedContentType }, + where: { + content_type: normalizedContentType, + ...tenantWhereFor(normalizedContentType, currentUser), + }, transaction, }); @@ -168,13 +262,15 @@ class ContentCatalogService { } static async delete(contentType: unknown, currentUser?: CurrentUser) { - assertCanManageContentCatalog(currentUser); - const normalizedContentType = assertValidContentType(contentType); + assertCanManageType(currentUser); return withTransaction(async (transaction) => { const record = await db.content_catalog.findOne({ - where: { content_type: normalizedContentType }, + where: { + content_type: normalizedContentType, + ...tenantWhereFor(normalizedContentType, currentUser), + }, transaction, }); diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts new file mode 100644 index 0000000..56495a1 --- /dev/null +++ b/backend/src/services/content_catalog_seed.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; + +afterEach(() => { + mock.restoreAll(); +}); + +describe('seedDefaultContentForTenant', () => { + test('seeds dashboard content at organization scope', async () => { + const createdRows: Array> = []; + + mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne); + mock.method(db.content_catalog, 'create', (async (payload: Record) => { + createdRows.push(payload); + return payload; + }) as typeof db.content_catalog.create); + + await seedDefaultContentForTenant({ + level: 'organization', + organizationId: 'org-1', + }); + + const quoteRow = createdRows.find((row) => + row.content_type === 'dashboard-encouraging-quotes', + ); + + assert.ok(quoteRow); + assert.equal(quoteRow.organizationId, 'org-1'); + assert.equal(quoteRow.schoolId, null); + assert.equal(quoteRow.campusId, null); + assert.equal(quoteRow.classId, null); + assert.equal(quoteRow.active, true); + }); +}); diff --git a/backend/src/services/content_catalog_seed.ts b/backend/src/services/content_catalog_seed.ts new file mode 100644 index 0000000..43f70e2 --- /dev/null +++ b/backend/src/services/content_catalog_seed.ts @@ -0,0 +1,112 @@ +import type { Transaction } from 'sequelize'; +import db from '@/db/models'; +import { + PER_TENANT_CONTENT_TYPES, + SCHOOL_SCOPED_CONTENT_TYPES, + ORG_SCOPED_CONTENT_TYPES, +} from '@/shared/constants/content-catalog'; +import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads'; + +export type TenantSeedLevel = 'organization' | 'school' | 'campus'; + +export interface TenantSeedContext { + level: TenantSeedLevel; + organizationId: string; + schoolId?: string | null; + campusId?: string | null; +} + +interface OwnerStamp { + organizationId: string | null; + schoolId: string | null; + campusId: string | null; +} + +/** + * The owning-tenant ids a content type takes when the given tenant level is + * created — or `null` if this type is not preset at this level. Per-tenant + * safety quiz exists at org/school/campus; dashboard + parent templates only at + * org/school/campus; org-scoped only at org; school-scoped only at school; truly + * global types are seeded once (with no tenant) when the first org is created. + */ +function stampForLevel( + contentType: string, + ctx: TenantSeedContext, +): OwnerStamp | null { + const org = ctx.organizationId; + + if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) { + return ctx.level === 'organization' + ? { organizationId: org, schoolId: null, campusId: null } + : null; + } + + if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { + return ctx.level === 'school' && ctx.schoolId + ? { organizationId: org, schoolId: ctx.schoolId, campusId: null } + : null; + } + + if (PER_TENANT_CONTENT_TYPES.has(contentType)) { + if (ctx.level === 'organization') { + return { organizationId: org, schoolId: null, campusId: null }; + } + if (ctx.level === 'school') { + return ctx.schoolId + ? { organizationId: org, schoolId: ctx.schoolId, campusId: null } + : null; + } + return ctx.campusId + ? { organizationId: org, schoolId: null, campusId: ctx.campusId } + : null; + } + + // Truly global/shared content: seeded once (no tenant) at org creation. + return ctx.level === 'organization' + ? { organizationId: null, schoolId: null, campusId: null } + : null; +} + +/** + * Copies the default content a newly-created tenant owns into `content_catalog`, + * once (idempotent — skips rows that already exist for that tenant). Called from + * the org/school/campus creation flows so new tenants start with editable + * defaults from the single defaults source. + */ +export async function seedDefaultContentForTenant( + ctx: TenantSeedContext, + transaction?: Transaction, +): Promise { + for (const row of CONTENT_CATALOG_DEFAULT_ROWS) { + const stamp = stampForLevel(row.content_type, ctx); + if (!stamp) { + continue; + } + const where = { + content_type: row.content_type, + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: null, + }; + const existing = await db.content_catalog.findOne({ + where, + paranoid: false, + transaction, + }); + if (existing) { + continue; + } + await db.content_catalog.create( + { + content_type: row.content_type, + payload: row.payload, + active: true, + importHash: null, + ...stamp, + classId: null, + }, + { transaction }, + ); + } +} diff --git a/backend/src/services/direct_messages.helpers.ts b/backend/src/services/direct_messages.helpers.ts new file mode 100644 index 0000000..6561485 --- /dev/null +++ b/backend/src/services/direct_messages.helpers.ts @@ -0,0 +1,32 @@ +import type { + DirectMessageContact, + DirectMessagePersonLike, +} from '@/services/direct_messages.types'; + +export function displayName(user: DirectMessagePersonLike | null): string { + if (!user) return '—'; + const full = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + return full || user.email || '—'; +} + +export function conversationKey(userId: string, studentId: string | null): string { + return `${userId}:${studentId ?? 'none'}`; +} + +export function studentWhere(studentId: string | null) { + return studentId ? { studentId } : { studentId: null }; +} + +/** Removes duplicate contacts (a staff member can connect via several students). */ +export function dedupeContacts( + rows: readonly DirectMessageContact[], +): DirectMessageContact[] { + const seen = new Set(); + const result: DirectMessageContact[] = []; + for (const row of rows) { + if (seen.has(row.conversationKey)) continue; + seen.add(row.conversationKey); + result.push(row); + } + return result; +} diff --git a/backend/src/services/direct_messages.test.ts b/backend/src/services/direct_messages.test.ts new file mode 100644 index 0000000..3fd949b --- /dev/null +++ b/backend/src/services/direct_messages.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import db from '@/db/models'; +import DirectMessagesService from '@/services/direct_messages'; +import { createTestUser } from '@/test-utils'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import ForbiddenError from '@/shared/errors/forbidden'; +import type { CurrentUser } from '@/db/api/types'; + +interface UserRow { + id: string; + firstName?: string | null; + lastName?: string | null; + email?: string | null; + classId?: string | null; + campusId?: string | null; + app_role?: { name: string }; +} + +interface GuardianStudentRow { + guardianId: string; + studentId: string; +} + +interface ClassRow { + id: string; + campusId: string | null; +} + +interface DirectMessageRow { + id: string; + senderId: string; + recipientId: string; + studentId: string | null; + body: string; + readAt: Date | null; + createdAt: Date; +} + +type UsersFindAllResult = Awaited>; +type GuardianStudentsFindAllResult = Awaited>; +type ClassesFindAllResult = Awaited>; +type DirectMessagesFindAllResult = Awaited>; +type DirectMessagesCreateResult = Awaited>; + +function users(rows: readonly UserRow[]): UsersFindAllResult { + return rows as unknown as UsersFindAllResult; +} + +function guardianLinks(rows: readonly GuardianStudentRow[]): GuardianStudentsFindAllResult { + return rows as unknown as GuardianStudentsFindAllResult; +} + +function classes(rows: readonly ClassRow[]): ClassesFindAllResult { + return rows as unknown as ClassesFindAllResult; +} + +function messages(rows: readonly DirectMessageRow[]): DirectMessagesFindAllResult { + return rows as unknown as DirectMessagesFindAllResult; +} + +function createdMessage(row: DirectMessageRow): DirectMessagesCreateResult { + return row as unknown as DirectMessagesCreateResult; +} + +function teacher(overrides: Partial = {}): CurrentUser { + return createTestUser({ + id: 'teacher-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + classId: 'class-1', + app_role: { + name: ROLE_NAMES.TEACHER, + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.READ_PARENT_COMM }], + }, + ...overrides, + }); +} + +function guardian(overrides: Partial = {}): CurrentUser { + return createTestUser({ + id: 'guardian-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + app_role: { + name: ROLE_NAMES.GUARDIAN, + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.READ_PARENT_COMM }], + }, + ...overrides, + }); +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('DirectMessagesService.contacts', () => { + test('does not let global access imply parent communication access', async () => { + await assert.rejects( + () => DirectMessagesService.contacts(createTestUser({ + app_role: { name: ROLE_NAMES.SUPER_ADMIN, globalAccess: true }, + })), + ForbiddenError, + ); + }); + + test('teacher sees guardian contacts separated by student', async () => { + let usersFindAllCall = 0; + const usersFindAll = (async () => { + usersFindAllCall += 1; + if (usersFindAllCall === 1) { + return users([ + { id: 'student-1', firstName: 'Amy', lastName: 'Adams', classId: 'class-1', campusId: 'campus-1' }, + { id: 'student-2', firstName: 'Ben', lastName: 'Brown', classId: 'class-1', campusId: 'campus-1' }, + ]); + } + return users([ + { id: 'guardian-1', firstName: 'Pat', lastName: 'Adams' }, + { id: 'guardian-2', firstName: 'Robin', lastName: 'Brown' }, + ]); + }) as typeof db.users.findAll; + + mock.method(db.users, 'findAll', usersFindAll); + mock.method(db.guardian_students, 'findAll', (async () => guardianLinks([ + { guardianId: 'guardian-1', studentId: 'student-1' }, + { guardianId: 'guardian-2', studentId: 'student-2' }, + ])) as typeof db.guardian_students.findAll); + + const result = await DirectMessagesService.contacts(teacher()); + + assert.deepEqual( + result.rows.map((row) => ({ + key: row.conversationKey, + userId: row.userId, + studentId: row.studentId, + studentName: row.studentName, + })), + [ + { key: 'guardian-1:student-1', userId: 'guardian-1', studentId: 'student-1', studentName: 'Amy Adams' }, + { key: 'guardian-2:student-2', userId: 'guardian-2', studentId: 'student-2', studentName: 'Ben Brown' }, + ], + ); + }); + + test('guardian sees teacher and office manager through linked student', async () => { + let usersFindAllCall = 0; + const usersFindAll = (async () => { + usersFindAllCall += 1; + if (usersFindAllCall === 1) { + return users([ + { id: 'student-1', firstName: 'Amy', lastName: 'Adams', classId: 'class-1', campusId: null }, + ]); + } + if (usersFindAllCall === 2) { + return users([ + { id: 'teacher-1', firstName: 'Tina', lastName: 'Teacher', classId: 'class-1', app_role: { name: ROLE_NAMES.TEACHER } }, + ]); + } + return users([ + { id: 'office-1', firstName: 'Oscar', lastName: 'Office', campusId: 'campus-1', app_role: { name: ROLE_NAMES.OFFICE_MANAGER } }, + ]); + }) as typeof db.users.findAll; + + mock.method(db.guardian_students, 'findAll', (async () => guardianLinks([ + { guardianId: 'guardian-1', studentId: 'student-1' }, + ])) as typeof db.guardian_students.findAll); + mock.method(db.classes, 'findAll', (async () => classes([ + { id: 'class-1', campusId: 'campus-1' }, + ])) as typeof db.classes.findAll); + mock.method(db.users, 'findAll', usersFindAll); + + const result = await DirectMessagesService.contacts(guardian()); + + assert.deepEqual( + result.rows.map((row) => ({ + key: row.conversationKey, + userId: row.userId, + role: row.role, + studentId: row.studentId, + })), + [ + { key: 'teacher-1:student-1', userId: 'teacher-1', role: ROLE_NAMES.TEACHER, studentId: 'student-1' }, + { key: 'office-1:student-1', userId: 'office-1', role: ROLE_NAMES.OFFICE_MANAGER, studentId: 'student-1' }, + ], + ); + }); +}); + +describe('DirectMessagesService.conversations', () => { + test('keeps same counterpart separated by student', async () => { + const firstAt = new Date('2026-06-14T10:00:00.000Z'); + const secondAt = new Date('2026-06-14T11:00:00.000Z'); + + mock.method(db.direct_messages, 'findAll', (async () => messages([ + { + id: 'm-2', + senderId: 'guardian-1', + recipientId: 'teacher-1', + studentId: 'student-2', + body: 'About Ben', + readAt: null, + createdAt: secondAt, + }, + { + id: 'm-1', + senderId: 'guardian-1', + recipientId: 'teacher-1', + studentId: 'student-1', + body: 'About Amy', + readAt: null, + createdAt: firstAt, + }, + ])) as typeof db.direct_messages.findAll); + + let usersFindAllCall = 0; + const usersFindAll = (async () => { + usersFindAllCall += 1; + if (usersFindAllCall === 1) { + return users([{ id: 'guardian-1', firstName: 'Pat', lastName: 'Adams' }]); + } + return users([ + { id: 'student-1', firstName: 'Amy', lastName: 'Adams' }, + { id: 'student-2', firstName: 'Ben', lastName: 'Brown' }, + ]); + }) as typeof db.users.findAll; + mock.method(db.users, 'findAll', usersFindAll); + + const result = await DirectMessagesService.conversations(teacher()); + + assert.deepEqual( + result.rows.map((row) => ({ + key: row.conversationKey, + studentId: row.studentId, + studentName: row.studentName, + unread: row.unread, + })), + [ + { key: 'guardian-1:student-2', studentId: 'student-2', studentName: 'Ben Brown', unread: 1 }, + { key: 'guardian-1:student-1', studentId: 'student-1', studentName: 'Amy Adams', unread: 1 }, + ], + ); + }); +}); + +describe('DirectMessagesService thread/send access', () => { + function mockTeacherContactsForTwoStudents(): void { + let usersFindAllCall = 0; + const usersFindAll = (async () => { + usersFindAllCall += 1; + if (usersFindAllCall === 1) { + return users([ + { id: 'student-1', firstName: 'Amy', lastName: 'Adams', classId: 'class-1' }, + { id: 'student-2', firstName: 'Ben', lastName: 'Brown', classId: 'class-1' }, + ]); + } + return users([{ id: 'guardian-1', firstName: 'Pat', lastName: 'Adams' }]); + }) as typeof db.users.findAll; + + mock.method(db.users, 'findAll', usersFindAll); + mock.method(db.guardian_students, 'findAll', (async () => guardianLinks([ + { guardianId: 'guardian-1', studentId: 'student-1' }, + { guardianId: 'guardian-1', studentId: 'student-2' }, + ])) as typeof db.guardian_students.findAll); + } + + test('thread rejects ambiguous same-counterpart access without studentId', async () => { + mockTeacherContactsForTwoStudents(); + + await assert.rejects( + () => DirectMessagesService.thread('guardian-1', undefined, teacher()), + ForbiddenError, + ); + }); + + test('send stores the selected student context', async () => { + mockTeacherContactsForTwoStudents(); + const commits: string[] = []; + const createdRows: unknown[] = []; + + mock.method(db.sequelize, 'transaction', (async () => ({ + commit: async () => { commits.push('commit'); }, + rollback: async () => { commits.push('rollback'); }, + })) as typeof db.sequelize.transaction); + + mock.method(db.direct_messages, 'create', (async (data: unknown) => { + createdRows.push(data); + return createdMessage({ + id: 'message-1', + senderId: 'teacher-1', + recipientId: 'guardian-1', + studentId: 'student-2', + body: 'Hello', + readAt: null, + createdAt: new Date('2026-06-14T12:00:00.000Z'), + }); + }) as typeof db.direct_messages.create); + + const result = await DirectMessagesService.send( + { recipientId: 'guardian-1', studentId: 'student-2', body: 'Hello' }, + teacher(), + ); + + assert.equal(result.id, 'message-1'); + assert.equal(commits.length, 1); + assert.deepEqual(createdRows, [{ + senderId: 'teacher-1', + recipientId: 'guardian-1', + studentId: 'student-2', + body: 'Hello', + organizationId: 'org-1', + createdById: 'teacher-1', + updatedById: 'teacher-1', + }]); + }); +}); diff --git a/backend/src/services/direct_messages.ts b/backend/src/services/direct_messages.ts new file mode 100644 index 0000000..c909579 --- /dev/null +++ b/backend/src/services/direct_messages.ts @@ -0,0 +1,405 @@ +import { Op } from 'sequelize'; +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ValidationError from '@/shared/errors/validation'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { + assertAuthenticatedTenantUser, + getRoleName, + getCampusId, + getClassId, + hasFeaturePermission, + getOrganizationId, + requireUserId, +} from '@/services/shared/access'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { + conversationKey, + dedupeContacts, + displayName, + studentWhere, +} from '@/services/direct_messages.helpers'; +import type { CurrentUser } from '@/db/api/types'; +import type { Users } from '@/db/models/users'; +import type { + DirectMessageContact, + DirectMessageInput, +} from '@/services/direct_messages.types'; + +/** Eager-loads users with their role name, filtered to the given role names. */ +function withRole(names: readonly string[]) { + return { + model: db.roles, + as: 'app_role', + where: { name: { [Op.in]: names } }, + required: true, + attributes: ['name'], + }; +} + +const USER_ATTRS = ['id', 'firstName', 'lastName', 'email', 'classId', 'campusId']; + +function assertCanUseDirectMessages(currentUser?: CurrentUser): void { + if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.READ_PARENT_COMM)) { + throw new ForbiddenError(); + } +} + +async function campusIdsByClassId(classIds: readonly string[]): Promise> { + const uniqueClassIds = [...new Set(classIds)]; + if (!uniqueClassIds.length) return new Map(); + const classes = await db.classes.findAll({ + where: { id: { [Op.in]: uniqueClassIds } }, + attributes: ['id', 'campusId'], + }); + return new Map( + classes + .filter((row) => row.campusId) + .map((row) => [row.id, row.campusId as string]), + ); +} + +async function campusIdForStudents(students: readonly Users[]): Promise> { + const classIds = students.map((student) => student.classId).filter(Boolean) as string[]; + const classCampusIds = await campusIdsByClassId(classIds); + return new Map(students.map((student) => [ + student.id, + student.campusId ?? (student.classId ? classCampusIds.get(student.classId) ?? null : null), + ])); +} + +/** + * Direct 1:1 conversations between staff (teacher / office_manager) and + * guardians, discovered through a shared student. Access is + * membership-based, so each conversation is isolated to its two participants. + */ +export default class DirectMessagesService { + /** The students the current user is connected to (own class/campus or wards). */ + private static async myStudents(currentUser: CurrentUser): Promise { + const role = getRoleName(currentUser); + const me = requireUserId(currentUser); + + if (role === ROLE_NAMES.GUARDIAN) { + const links = await db.guardian_students.findAll({ + where: { guardianId: me }, + attributes: ['studentId'], + }); + const studentIds = [...new Set(links.map((l) => l.studentId))]; + if (!studentIds.length) return []; + return db.users.findAll({ + where: { id: { [Op.in]: studentIds } }, + attributes: USER_ATTRS, + }); + } + + // Staff: students of their class (teacher) or campus (office_manager). + if (role === ROLE_NAMES.OFFICE_MANAGER) { + const campusId = getCampusId(currentUser); + if (!campusId) return []; + const classes = await db.classes.findAll({ + where: { campusId }, + attributes: ['id'], + }); + const classIds = classes.map((row) => row.id); + return db.users.findAll({ + where: { + [Op.or]: [ + { campusId }, + ...(classIds.length ? [{ classId: { [Op.in]: classIds } }] : []), + ], + }, + include: [withRole([ROLE_NAMES.STUDENT])], + attributes: USER_ATTRS, + }); + } + + const classId = getClassId(currentUser); + if (!classId) return []; + return db.users.findAll({ + where: { classId }, + include: [withRole([ROLE_NAMES.STUDENT])], + attributes: USER_ATTRS, + }); + } + + /** People the current user may start/continue a conversation with. */ + static async contacts(currentUser?: CurrentUser): Promise<{ rows: DirectMessageContact[] }> { + assertAuthenticatedTenantUser(currentUser); + assertCanUseDirectMessages(currentUser); + const user = currentUser as CurrentUser; + const role = getRoleName(user); + const students = await this.myStudents(user); + if (!students.length) return { rows: [] }; + + if (role === ROLE_NAMES.GUARDIAN) { + const classIds = [...new Set(students.map((s) => s.classId).filter(Boolean))] as string[]; + const campusByStudent = await campusIdForStudents(students); + const campusIds = [...new Set([...campusByStudent.values()].filter(Boolean))] as string[]; + + const teachers = classIds.length + ? await db.users.findAll({ + where: { classId: { [Op.in]: classIds } }, + include: [withRole([ROLE_NAMES.TEACHER])], + attributes: USER_ATTRS, + }) + : []; + const offices = campusIds.length + ? await db.users.findAll({ + where: { campusId: { [Op.in]: campusIds } }, + include: [withRole([ROLE_NAMES.OFFICE_MANAGER])], + attributes: USER_ATTRS, + }) + : []; + + const rows: DirectMessageContact[] = []; + for (const staff of teachers) { + for (const student of students.filter((s) => s.classId && s.classId === staff.classId)) { + rows.push({ + conversationKey: conversationKey(staff.id, student.id), + userId: staff.id, + name: displayName(staff), + role: staff.app_role?.name ?? null, + studentId: student.id, + studentName: displayName(student), + }); + } + } + for (const staff of offices) { + for (const student of students.filter((s) => campusByStudent.get(s.id) === staff.campusId)) { + rows.push({ + conversationKey: conversationKey(staff.id, student.id), + userId: staff.id, + name: displayName(staff), + role: staff.app_role?.name ?? null, + studentId: student.id, + studentName: displayName(student), + }); + } + } + return { rows: dedupeContacts(rows) }; + } + + // Staff → the guardians of their students. + const studentIds = students.map((s) => s.id); + const links = await db.guardian_students.findAll({ + where: { studentId: { [Op.in]: studentIds } }, + attributes: ['guardianId', 'studentId'], + }); + if (!links.length) return { rows: [] }; + const guardianIds = [...new Set(links.map((l) => l.guardianId))]; + const guardians = await db.users.findAll({ + where: { id: { [Op.in]: guardianIds } }, + attributes: USER_ATTRS, + }); + const studentById = new Map(students.map((s) => [s.id, s])); + const guardianById = new Map(guardians.map((guardian) => [guardian.id, guardian])); + const rows: DirectMessageContact[] = links.flatMap((link) => { + const guardian = guardianById.get(link.guardianId); + const student = studentById.get(link.studentId); + if (!guardian || !student) return []; + return [{ + conversationKey: conversationKey(guardian.id, student.id), + userId: guardian.id, + name: displayName(guardian), + role: ROLE_NAMES.GUARDIAN, + studentId: student.id, + studentName: displayName(student), + }]; + }); + return { rows: dedupeContacts(rows) }; + } + + /** Whether the current user may message `otherUserId` for a specific student. */ + private static async assertCanMessage( + currentUser: CurrentUser, + otherUserId: string, + studentIdRaw?: unknown, + ): Promise { + const requestedStudentId = typeof studentIdRaw === 'string' && studentIdRaw + ? studentIdRaw + : null; + const { rows } = await this.contacts(currentUser); + const matches = rows.filter((row) => row.userId === otherUserId); + const contact = requestedStudentId + ? matches.find((row) => row.studentId === requestedStudentId) + : matches.length === 1 + ? matches[0] + : undefined; + if (!contact) { + throw new ForbiddenError(); + } + return contact; + } + + /** The current user's conversations (one row per counterpart + student). */ + static async conversations(currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + assertCanUseDirectMessages(currentUser); + const me = requireUserId(currentUser); + + const messages = await db.direct_messages.findAll({ + where: { [Op.or]: [{ senderId: me }, { recipientId: me }] }, + order: [['createdAt', 'DESC']], + }); + + const byConversation = new Map< + string, + { otherUserId: string; studentId: string | null; lastBody: string; lastAt: Date; unread: number } + >(); + for (const message of messages) { + const other = message.senderId === me ? message.recipientId : message.senderId; + const key = conversationKey(other, message.studentId ?? null); + const existing = byConversation.get(key); + const isUnread = message.recipientId === me && message.readAt === null; + if (!existing) { + byConversation.set(key, { + otherUserId: other, + studentId: message.studentId ?? null, + lastBody: message.body, + lastAt: message.createdAt, + unread: isUnread ? 1 : 0, + }); + } else if (isUnread) { + existing.unread += 1; + } + } + + const conversations = [...byConversation.values()]; + const counterpartIds = [...new Set(conversations.map((row) => row.otherUserId))]; + const studentIds = [...new Set(conversations.map((row) => row.studentId).filter(Boolean))] as string[]; + if (!counterpartIds.length) return { rows: [] }; + const users = await db.users.findAll({ + where: { id: { [Op.in]: counterpartIds } }, + attributes: USER_ATTRS, + }); + const students = studentIds.length + ? await db.users.findAll({ + where: { id: { [Op.in]: studentIds } }, + attributes: USER_ATTRS, + }) + : []; + const userById = new Map(users.map((u) => [u.id, u])); + const studentById = new Map(students.map((u) => [u.id, u])); + + const rows = conversations.map((info) => { + const other = userById.get(info.otherUserId) ?? null; + const student = info.studentId ? studentById.get(info.studentId) ?? null : null; + return { + conversationKey: conversationKey(info.otherUserId, info.studentId), + userId: info.otherUserId, + name: displayName(other), + studentId: info.studentId, + studentName: student ? displayName(student) : null, + lastMessage: info.lastBody, + lastAt: info.lastAt, + unread: info.unread, + }; + }); + rows.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0)); + return { rows }; + } + + /** Messages between the current user and `otherUserId`; marks incoming read. */ + static async thread( + otherUserIdRaw: unknown, + studentIdRaw?: unknown, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + assertCanUseDirectMessages(currentUser); + const user = currentUser as CurrentUser; + const me = requireUserId(user); + const otherUserId = typeof otherUserIdRaw === 'string' ? otherUserIdRaw : ''; + if (!otherUserId) throw new ValidationError(); + const contact = await this.assertCanMessage(user, otherUserId, studentIdRaw); + const scopedStudentId = contact.studentId; + + const messages = await db.direct_messages.findAll({ + where: { + [Op.or]: [ + { senderId: me, recipientId: otherUserId }, + { senderId: otherUserId, recipientId: me }, + ], + ...studentWhere(scopedStudentId), + }, + order: [['createdAt', 'ASC']], + }); + + // Mark messages addressed to me as read. + await db.direct_messages.update( + { readAt: new Date() }, + { + where: { + senderId: otherUserId, + recipientId: me, + readAt: null, + ...studentWhere(scopedStudentId), + }, + }, + ); + + const other = await db.users.findByPk(otherUserId, { attributes: USER_ATTRS }); + + return { + other: other ? { + userId: other.id, + name: displayName(other), + studentId: contact.studentId, + studentName: contact.studentName, + } : null, + rows: messages.map((message) => ({ + id: message.id, + body: message.body, + senderId: message.senderId, + mine: message.senderId === me, + createdAt: message.createdAt, + })), + }; + } + + /** Sends a message to `recipientId` (must be a valid contact). */ + static async send( + data: DirectMessageInput, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + assertCanUseDirectMessages(currentUser); + const user = currentUser as CurrentUser; + const me = requireUserId(user); + + const recipientId = typeof data?.recipientId === 'string' ? data.recipientId : ''; + const body = typeof data?.body === 'string' ? data.body.trim() : ''; + if (!recipientId || !body) { + throw new ValidationError(); + } + + const contact = await this.assertCanMessage(user, recipientId, data?.studentId); + const studentId = + typeof data?.studentId === 'string' && data.studentId + ? data.studentId + : contact.studentId; + + return withTransaction(async (transaction) => { + const message = await db.direct_messages.create( + { + senderId: me, + recipientId, + studentId: studentId ?? null, + body, + organizationId: getOrganizationId(user), + createdById: me, + updatedById: me, + }, + { transaction }, + ); + return { + id: message.id, + body: message.body, + senderId: message.senderId, + mine: true, + createdAt: message.createdAt, + }; + }); + } +} diff --git a/backend/src/services/direct_messages.types.ts b/backend/src/services/direct_messages.types.ts new file mode 100644 index 0000000..6eda411 --- /dev/null +++ b/backend/src/services/direct_messages.types.ts @@ -0,0 +1,20 @@ +export interface DirectMessageContact { + conversationKey: string; + userId: string; + name: string; + role: string | null; + studentId: string | null; + studentName: string | null; +} + +export interface DirectMessageInput { + recipientId?: unknown; + body?: unknown; + studentId?: unknown; +} + +export interface DirectMessagePersonLike { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +} diff --git a/backend/src/services/file-access.ts b/backend/src/services/file-access.ts index 9b9bb58..4397879 100644 --- a/backend/src/services/file-access.ts +++ b/backend/src/services/file-access.ts @@ -1,35 +1,21 @@ -import FileDBApi from '@/db/api/file'; import ForbiddenError from '@/shared/errors/forbidden'; -import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access'; import type { CurrentUser } from '@/db/api/types'; /** - * Per-file tenant/ownership check for downloads (Workstream 3 §3.5 / file - * workstream). Downloads serve a file by its `privateUrl` path; without this an - * authenticated user of one tenant could fetch another tenant's file by knowing - * (or guessing) the path. Global-access (system) roles bypass; everyone else may - * only fetch a file owned by their own organization. Files with no tracked row - * are denied by default. + * Download authorization. Per-file tenant/ownership enforcement was **removed** + * (customer decision): downloads now require only a valid authenticated session + * (JWT), not per-file organization ownership. This unblocks the logo/avatar/ + * document upload+download flow, whose standalone `/file/upload/:table/:field` + * path does not create a tracked `file` row. * - * Kept out of `services/file.ts` (which is coupled to Express req/res streaming) - * to avoid a circular dependency with `db/api/file.ts`. + * Trade-off: any authenticated user can fetch a file by its `privateUrl` path, + * including across organizations. Accepted by the customer for this stage. */ export async function assertCanDownloadFile( - privateUrl: string, + _privateUrl: string, currentUser?: CurrentUser, ): Promise { if (!currentUser?.id) { throw new ForbiddenError(); } - if (hasGlobalAccess(currentUser)) { - return; - } - - const { found, organizationId } = - await FileDBApi.findOwnerOrganizationIdByPrivateUrl(privateUrl); - - const requesterOrganizationId = getOrganizationId(currentUser); - if (!found || !organizationId || organizationId !== requesterOrganizationId) { - throw new ForbiddenError(); - } } diff --git a/backend/src/services/frame_entries.ts b/backend/src/services/frame_entries.ts index 68fbe43..d3acb4b 100644 --- a/backend/src/services/frame_entries.ts +++ b/backend/src/services/frame_entries.ts @@ -4,10 +4,12 @@ import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; import { - getOrganizationIdOrGlobal, - hasRoleAccess, + getOwnTenant, + tenantExactWhere, + tenantStamp, + hasFeaturePermission, } from '@/services/shared/access'; -import { FRAME_EDITOR_ROLE_NAMES } from '@/shared/constants/frame'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { toWeekStartIso } from '@/shared/constants/week'; import type { FrameEntries } from '@/db/models/frame_entries'; import type { CurrentUser } from '@/db/api/types'; @@ -50,24 +52,13 @@ const REQUIRED_FIELDS = [ 'author', ] as const; -function getCampusId( - currentUser: CurrentUser | undefined, - data: FrameEntryInput, -): string | null { - if (data.campusId) { - return data.campusId; - } - - const staff = currentUser?.staff_user; - if (Array.isArray(staff) && staff[0]?.campusId) { - return staff[0].campusId; - } - - return null; -} - function assertCanEdit(currentUser?: CurrentUser): void { - if (hasRoleAccess(currentUser, FRAME_EDITOR_ROLE_NAMES)) { + if ( + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.MANAGE_FRAME, + ) + ) { return; } @@ -105,6 +96,8 @@ function toDto(entry: FrameEntries) { author: plain.author, organizationId: plain.organizationId, campusId: plain.campusId, + schoolId: plain.schoolId, + classId: plain.classId, createdAt: plain.createdAt, updatedAt: plain.updatedAt, }; @@ -115,10 +108,11 @@ class FrameEntriesService { filter: { limit?: number | string; page?: number | string } = {}, currentUser?: CurrentUser, ) { - const organizationId = getOrganizationIdOrGlobal(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); - const where = organizationId ? { organizationId } : {}; + // Per-tenant content: a user sees the FRAME entries dedicated to their own + // tenant level (org/school/campus/class), not an aggregate of children. + const where = tenantExactWhere(getOwnTenant(currentUser)); const result = await db.frame_entries.findAndCountAll({ where, @@ -137,7 +131,9 @@ class FrameEntriesService { assertCanEdit(currentUser); assertValidFrameEntry(data); - const organizationId = getOrganizationIdOrGlobal(currentUser); + // New entries are stamped to the author's own tenant level (drill-down + // authoring of a child tenant arrives with the scope-context UI). + const stamp = tenantStamp(getOwnTenant(currentUser)); return withTransaction(async (transaction) => { const entry = await db.frame_entries.create( @@ -151,8 +147,10 @@ class FrameEntriesService { management: data.management.trim(), emotional: data.emotional.trim(), author: data.author.trim(), - organizationId, - campusId: getCampusId(currentUser, data), + organizationId: stamp.organizationId, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: stamp.classId, createdById: currentUser?.id ?? null, updatedById: currentUser?.id ?? null, }, @@ -171,8 +169,8 @@ class FrameEntriesService { assertCanEdit(currentUser); assertValidFrameEntry(data); - const organizationId = getOrganizationIdOrGlobal(currentUser); - const where = organizationId ? { id, organizationId } : { id }; + // Only entries owned by the user's own tenant level are editable here. + const where = { id, ...tenantExactWhere(getOwnTenant(currentUser)) }; return withTransaction(async (transaction) => { const entry = await db.frame_entries.findOne({ @@ -195,7 +193,6 @@ class FrameEntriesService { management: data.management.trim(), emotional: data.emotional.trim(), author: data.author.trim(), - campusId: getCampusId(currentUser, data), updatedById: currentUser?.id ?? null, }, { transaction }, @@ -208,8 +205,7 @@ class FrameEntriesService { static async destroy(id: string, currentUser?: CurrentUser) { assertCanEdit(currentUser); - const organizationId = getOrganizationIdOrGlobal(currentUser); - const where = organizationId ? { id, organizationId } : { id }; + const where = { id, ...tenantExactWhere(getOwnTenant(currentUser)) }; return withTransaction(async (transaction) => { const entry = await db.frame_entries.findOne({ where, transaction }); diff --git a/backend/src/services/guardian_students.ts b/backend/src/services/guardian_students.ts new file mode 100644 index 0000000..933afd1 --- /dev/null +++ b/backend/src/services/guardian_students.ts @@ -0,0 +1,123 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ValidationError from '@/shared/errors/validation'; +import { getOrganizationIdOrGlobal } from '@/services/shared/access'; +import type { GuardianStudents } from '@/db/models/guardian_students'; +import type { CurrentUser } from '@/db/api/types'; + +interface LinkInput { + guardianId?: string; + studentId?: string; + relationship?: string | null; +} + +interface GuardianStudentsFilter { + studentId?: string; + guardianId?: string; +} + +function orgFilter(currentUser?: CurrentUser): { organizationId?: string } { + const organizationId = getOrganizationIdOrGlobal(currentUser); + return organizationId ? { organizationId } : {}; +} + +function toDto(row: GuardianStudents) { + const plain = row.get({ plain: true }) as Record & { + guardian?: { id?: string; firstName?: string; lastName?: string } | null; + student?: { id?: string; firstName?: string; lastName?: string } | null; + }; + return { + id: plain.id, + guardianId: plain.guardianId, + studentId: plain.studentId, + relationship: plain.relationship, + guardian: plain.guardian + ? { + id: plain.guardian.id, + firstName: plain.guardian.firstName, + lastName: plain.guardian.lastName, + } + : null, + student: plain.student + ? { + id: plain.student.id, + firstName: plain.student.firstName, + lastName: plain.student.lastName, + } + : null, + }; +} + +class GuardianStudentsService { + /** Idempotent link of a guardian to a student (tenant-scoped). */ + static async link(data: LinkInput, currentUser?: CurrentUser) { + if (!data?.guardianId || !data?.studentId) { + throw new ValidationError(); + } + const guardianId = data.guardianId; + const studentId = data.studentId; + const where = orgFilter(currentUser); + + return withTransaction(async (transaction) => { + const existing = await db.guardian_students.findOne({ + where: { guardianId, studentId, ...where }, + transaction, + }); + if (existing) { + if (data.relationship !== undefined) { + await existing.update( + { relationship: data.relationship || null, updatedById: currentUser?.id ?? null }, + { transaction }, + ); + } + return toDto(existing); + } + const row = await db.guardian_students.create( + { + guardianId, + studentId, + relationship: data.relationship || null, + organizationId: where.organizationId ?? null, + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + return toDto(row); + }); + } + + static async unlink(id: string, currentUser?: CurrentUser): Promise { + const where = orgFilter(currentUser); + await withTransaction(async (transaction) => { + const row = await db.guardian_students.findOne({ + where: { id, ...where }, + transaction, + }); + if (!row) { + throw new ValidationError(); + } + await row.destroy({ transaction }); + }); + } + + /** Lists links, filtered by studentId or guardianId, with the other party loaded. */ + static async list(filter: GuardianStudentsFilter, currentUser?: CurrentUser) { + const where: Record = { ...orgFilter(currentUser) }; + if (filter.studentId) where.studentId = filter.studentId; + if (filter.guardianId) where.guardianId = filter.guardianId; + + const rows = await db.guardian_students.findAll({ + where, + include: [ + { model: db.users, as: 'guardian' }, + { model: db.users, as: 'student' }, + ], + order: [['createdAt', 'desc']], + }); + + return { rows: rows.map(toDto), count: rows.length }; + } +} + +export default GuardianStudentsService; diff --git a/backend/src/services/iam_capabilities.test.ts b/backend/src/services/iam_capabilities.test.ts new file mode 100644 index 0000000..4295434 --- /dev/null +++ b/backend/src/services/iam_capabilities.test.ts @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import IamCapabilitiesService from '@/services/iam_capabilities'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import type { CurrentUser } from '@/db/api/types'; + +function permission(name: string) { + return { name }; +} + +function user( + role: string, + scope: string, + permissionNames: readonly string[], + globalAccess = false, +): CurrentUser { + return { + id: `${role}-user`, + app_role: { + name: role, + scope, + globalAccess, + permissions: permissionNames.map(permission), + }, + }; +} + +const allTenantCreatePermissions = [ + 'CREATE_ORGANIZATIONS', + 'CREATE_SCHOOLS', + 'CREATE_CAMPUSES', + 'CREATE_CLASSES', + 'CREATE_USERS', +] as const; + +test('super_admin creates organizations through owner setup and can create child location types', () => { + const caps = IamCapabilitiesService.current( + user(ROLE_NAMES.SUPER_ADMIN, ROLE_SCOPES.SYSTEM, [], true), + ); + assert.deepEqual(caps.creatableTenantTypes, ['school', 'campus', 'class']); + assert.equal(caps.manageableRoleNames.includes(ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(caps.manageableRoleNames.includes(ROLE_NAMES.SYSTEM_ADMIN), true); + assert.equal(caps.canCreateOwnerWithOrganization, true); +}); + +test('system_admin cannot manage platform admin roles', () => { + const caps = IamCapabilitiesService.current( + user(ROLE_NAMES.SYSTEM_ADMIN, ROLE_SCOPES.SYSTEM, allTenantCreatePermissions, true), + ); + assert.equal(caps.manageableRoleNames.includes(ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(caps.manageableRoleNames.includes(ROLE_NAMES.SYSTEM_ADMIN), false); + assert.equal(caps.manageableRoleNames.includes(ROLE_NAMES.OWNER), true); +}); + +test('owner and superintendent create school/campus/class but not organization directly', () => { + for (const role of [ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT]) { + const caps = IamCapabilitiesService.current( + user(role, ROLE_SCOPES.ORGANIZATION, allTenantCreatePermissions), + ); + assert.deepEqual(caps.creatableTenantTypes, ['school', 'campus', 'class']); + } +}); + +test('principal creates campus/class, director creates class only', () => { + const principal = IamCapabilitiesService.current( + user(ROLE_NAMES.PRINCIPAL, ROLE_SCOPES.SCHOOL, allTenantCreatePermissions), + ); + assert.deepEqual(principal.creatableTenantTypes, ['campus', 'class']); + + const director = IamCapabilitiesService.current( + user(ROLE_NAMES.DIRECTOR, ROLE_SCOPES.CAMPUS, allTenantCreatePermissions), + ); + assert.deepEqual(director.creatableTenantTypes, ['class']); +}); + +test('read-only and external roles cannot create tenants or manage users', () => { + for (const role of [ + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, + ROLE_NAMES.STUDENT, + ROLE_NAMES.GUARDIAN, + ROLE_NAMES.GUEST, + ]) { + const caps = IamCapabilitiesService.current( + user(role, ROLE_SCOPES.CAMPUS, ['READ_USERS']), + ); + assert.deepEqual(caps.creatableTenantTypes, []); + assert.deepEqual(caps.manageableRoleNames, []); + } +}); diff --git a/backend/src/services/iam_capabilities.ts b/backend/src/services/iam_capabilities.ts new file mode 100644 index 0000000..5094008 --- /dev/null +++ b/backend/src/services/iam_capabilities.ts @@ -0,0 +1,76 @@ +import { ROLE_NAMES, ROLE_SCOPES, type RoleName } from '@/shared/constants/roles'; +import { hasFeaturePermission, getRoleScope } from '@/services/shared/access'; +import { manageableRoleNamesFor } from '@/services/shared/role-policy'; +import type { CurrentUser } from '@/db/api/types'; + +export type CreatableTenantType = 'school' | 'campus' | 'class'; + +export interface IamCapabilities { + readonly manageableRoleNames: readonly RoleName[]; + readonly creatableTenantTypes: readonly CreatableTenantType[]; + readonly canCreateOwnerWithOrganization: boolean; + readonly canEditOwnProfile: boolean; + readonly canEditOwnOrganization: boolean; + readonly canAssignPrincipal: boolean; + readonly canAssignDirector: boolean; +} + +function tenantTypesFor(currentUser?: CurrentUser): CreatableTenantType[] { + const scope = getRoleScope(currentUser); + const types: CreatableTenantType[] = []; + + if ( + hasFeaturePermission(currentUser, 'CREATE_SCHOOLS') + && ( + scope === ROLE_SCOPES.SYSTEM + || scope === ROLE_SCOPES.ORGANIZATION + ) + ) { + types.push('school'); + } + if ( + hasFeaturePermission(currentUser, 'CREATE_CAMPUSES') + && ( + scope === ROLE_SCOPES.SYSTEM + || scope === ROLE_SCOPES.ORGANIZATION + || scope === ROLE_SCOPES.SCHOOL + ) + ) { + types.push('campus'); + } + if ( + hasFeaturePermission(currentUser, 'CREATE_CLASSES') + && ( + scope === ROLE_SCOPES.SYSTEM + || scope === ROLE_SCOPES.ORGANIZATION + || scope === ROLE_SCOPES.SCHOOL + || scope === ROLE_SCOPES.CAMPUS + ) + ) { + types.push('class'); + } + + return types; +} + +class IamCapabilitiesService { + static current(currentUser?: CurrentUser): IamCapabilities { + const manageableRoleNames = manageableRoleNamesFor(currentUser); + return { + manageableRoleNames, + creatableTenantTypes: tenantTypesFor(currentUser), + canCreateOwnerWithOrganization: + manageableRoleNames.includes(ROLE_NAMES.OWNER) + && hasFeaturePermission(currentUser, 'CREATE_USERS') + && hasFeaturePermission(currentUser, 'CREATE_ORGANIZATIONS'), + canEditOwnProfile: Boolean(currentUser?.id), + canEditOwnOrganization: + hasFeaturePermission(currentUser, 'UPDATE_ORGANIZATIONS') + && getRoleScope(currentUser) === ROLE_SCOPES.ORGANIZATION, + canAssignPrincipal: manageableRoleNames.includes(ROLE_NAMES.PRINCIPAL), + canAssignDirector: manageableRoleNames.includes(ROLE_NAMES.DIRECTOR), + }; + } +} + +export default IamCapabilitiesService; diff --git a/backend/src/services/organizations.ts b/backend/src/services/organizations.ts index 97b9a16..52246af 100644 --- a/backend/src/services/organizations.ts +++ b/backend/src/services/organizations.ts @@ -1,4 +1,27 @@ import DbApi from '@/db/api/organizations'; import { createCrudService } from '@/services/shared/crud-service'; +import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; +import { withTransaction } from '@/db/with-transaction'; +import type { CurrentUser } from '@/db/api/types'; -export default createCrudService(DbApi, { notFoundCode: 'organizationsNotFound' }); +const base = createCrudService(DbApi, { notFoundCode: 'organizationsNotFound' }); + +export default { + ...base, + /** + * Creates an organization and presets its org-scoped default content (plus the + * truly-global seed-once rows) in the same transaction. Idempotent. + */ + async create( + data: Parameters[0], + currentUser?: CurrentUser, + ): Promise { + await withTransaction(async (transaction) => { + const organization = await DbApi.create(data, { currentUser, transaction }); + await seedDefaultContentForTenant( + { level: 'organization', organizationId: organization.id }, + transaction, + ); + }); + }, +}; diff --git a/backend/src/services/personal_scope_results.test.ts b/backend/src/services/personal_scope_results.test.ts new file mode 100644 index 0000000..4e2c8f9 --- /dev/null +++ b/backend/src/services/personal_scope_results.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import PersonalityQuizResultsService from '@/services/personality_quiz_results'; +import SafetyQuizResultsService from '@/services/safety_quiz_results'; +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 parentUserDrilledIntoSchool() { + return createTestUser({ + id: 'user-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + schoolId: null, + campusId: null, + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + permissions: [ + permission(FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS), + permission(FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS), + ], + }, + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + }); +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('personal result persistence while drilled into child scope', () => { + test('does not create safety quiz results for parent users in child scope', async () => { + let createCount = 0; + mock.method(db.safety_quiz_results, 'create', (async () => { + createCount += 1; + return null; + }) as unknown as typeof db.safety_quiz_results.create); + + const result = await SafetyQuizResultsService.create( + { + quiz_id: 'qbs-weekly', + quiz_title: 'QBS Weekly', + week_of: '2026-06-15', + score: 3, + total_questions: 4, + answers: [0, 1, 2, 3], + }, + parentUserDrilledIntoSchool(), + ); + + assert.equal(result, null); + assert.equal(createCount, 0); + }); + + test('does not upsert personality results for parent users in child scope', async () => { + let createCount = 0; + mock.method(db.personality_quiz_results, 'findOne', (async () => null) as unknown as typeof db.personality_quiz_results.findOne); + mock.method(db.personality_quiz_results, 'create', (async () => { + createCount += 1; + return null; + }) as unknown as typeof db.personality_quiz_results.create); + + const result = await PersonalityQuizResultsService.upsertCurrentUserResult( + { + personality_type: 'INFJ', + quiz_answers: { 1: 'A' }, + }, + parentUserDrilledIntoSchool(), + ); + + assert.equal(result, null); + assert.equal(createCount, 0); + }); +}); diff --git a/backend/src/services/personality_quiz_results.ts b/backend/src/services/personality_quiz_results.ts index e633fef..e8a7c5c 100644 --- a/backend/src/services/personality_quiz_results.ts +++ b/backend/src/services/personality_quiz_results.ts @@ -8,9 +8,10 @@ import { getCampusId, assertAuthenticatedTenantUser, campusDimensionScope, - hasRoleAccess, + hasFeaturePermission, + isActingInOwnScope, } from '@/services/shared/access'; -import { PERSONALITY_REPORT_ROLE_NAMES } from '@/shared/constants/personality'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { PersonalityQuizResults } from '@/db/models/personality_quiz_results'; import type { CurrentUser } from '@/db/api/types'; @@ -95,6 +96,10 @@ class PersonalityQuizResultsService { assertAuthenticatedTenantUser(currentUser); assertValidResult(data); + if (!isActingInOwnScope(currentUser)) { + return this.getCurrentUserResult(currentUser); + } + const organizationId = getOrganizationIdOrGlobal(currentUser); const orgFilter = organizationId ? { organizationId } : {}; const where = { @@ -140,7 +145,12 @@ class PersonalityQuizResultsService { ) { assertAuthenticatedTenantUser(currentUser); - if (!hasRoleAccess(currentUser, PERSONALITY_REPORT_ROLE_NAMES)) { + if ( + !hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS, + ) + ) { throw new ForbiddenError(); } diff --git a/backend/src/services/platform.ts b/backend/src/services/platform.ts new file mode 100644 index 0000000..444d565 --- /dev/null +++ b/backend/src/services/platform.ts @@ -0,0 +1,97 @@ +import { Op } from 'sequelize'; +import db from '@/db/models'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { hasGlobalAccess } from '@/services/shared/access'; +import type { CurrentUser } from '@/db/api/types'; + +export interface PlatformStats { + readonly tenants: { + readonly organizations: number; + readonly schools: number; + readonly campuses: number; + readonly classes: number; + }; + readonly users: { + readonly total: number; + /** Count per role name (e.g. `{ teacher: 12, owner: 1 }`). */ + readonly byRole: Record; + }; + readonly content: { + readonly frameEntries: number; + readonly quizzes: number; + readonly documents: number; + }; + readonly activeSessions: number; +} + +/** + * Platform-wide statistics for the global scope (super_admin / system_admin). + * Counts are unscoped on purpose — this is the only place tenant isolation does + * not apply, because the audience is the platform operator. + */ +export default class PlatformService { + static async stats(currentUser?: CurrentUser): Promise { + if (!hasGlobalAccess(currentUser)) { + throw new ForbiddenError(); + } + + const roles = await db.roles.findAll({ attributes: ['id', 'name'] }); + + const [ + organizations, + schools, + campuses, + classes, + totalUsers, + roleCounts, + frameEntries, + quizzes, + documents, + activeSessions, + ] = await Promise.all([ + db.organizations.count(), + db.schools.count(), + db.campuses.count(), + db.classes.count(), + db.users.count(), + Promise.all( + roles.map((role) => + db.users.count({ + include: [ + { model: db.roles, as: 'app_role', where: { id: role.id }, required: true }, + ], + }), + ), + ), + db.frame_entries.count(), + db.content_catalog.count({ + where: { content_type: { [Op.iLike]: '%quiz%' } }, + }), + db.policy_documents.count(), + db.auth_refresh_tokens.count({ + distinct: true, + col: 'userId', + where: { + revokedAt: { [Op.is]: null }, + expiresAt: { [Op.gt]: new Date() }, + }, + }), + ]); + + const byRole: Record = {}; + roles.forEach((role, index) => { + const name = role.name ?? 'unassigned'; + const count = roleCounts[index] ?? 0; + if (count > 0) { + byRole[name] = count; + } + }); + + return { + tenants: { organizations, schools, campuses, classes }, + users: { total: totalUsers, byRole }, + content: { frameEntries, quizzes, documents }, + activeSessions, + }; + } +} diff --git a/backend/src/services/policy_acknowledgments.test.ts b/backend/src/services/policy_acknowledgments.test.ts new file mode 100644 index 0000000..1f5e946 --- /dev/null +++ b/backend/src/services/policy_acknowledgments.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import PolicyAcknowledgmentsService from '@/services/policy_acknowledgments'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { createGlobalAccessUser, createTestUser } from '@/test-utils'; +import type { CurrentUser } from '@/db/api/types'; + +function permission(name: string) { + return { name }; +} + +function user(roleName: string, permissions: readonly string[] = []): CurrentUser { + return createTestUser({ + campusId: 'campus-1', + app_role: { + name: roleName, + globalAccess: false, + permissions: permissions.map(permission), + }, + }); +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('PolicyAcknowledgmentsService role eligibility', () => { + test('rejects global access without explicit ACK_POLICY', async () => { + const rejectedUsers = [ + createGlobalAccessUser(), + ]; + + for (const currentUser of rejectedUsers) { + await assert.rejects( + () => PolicyAcknowledgmentsService.list({}, currentUser), + ForbiddenError, + ); + } + }); + + test('allows an explicit ACK_POLICY permission regardless of role name', async () => { + mock.method(db.policy_acknowledgments, 'findAndCountAll', (async () => ({ + rows: [], + count: 0, + })) as unknown as typeof db.policy_acknowledgments.findAndCountAll); + + const result = await PolicyAcknowledgmentsService.list( + {}, + user(ROLE_NAMES.OWNER, [FEATURE_PERMISSIONS.ACK_POLICY]), + ); + + assert.deepEqual(result, { rows: [], count: 0 }); + }); + + test('does not persist acknowledgments while a parent user is drilled into a child scope', async () => { + let documentLookupCount = 0; + let acknowledgmentCreateCount = 0; + mock.method(db.policy_documents, 'findOne', (async () => { + documentLookupCount += 1; + return null; + }) as unknown as typeof db.policy_documents.findOne); + mock.method(db.policy_acknowledgments, 'create', (async () => { + acknowledgmentCreateCount += 1; + return null; + }) as unknown as typeof db.policy_acknowledgments.create); + + const result = await PolicyAcknowledgmentsService.acknowledge( + { policyDocumentId: 'document-1' }, + createTestUser({ + organizationId: 'org-1', + organizations: { id: 'org-1' }, + schoolId: null, + campusId: null, + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.ACK_POLICY)], + }, + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + }), + ); + + assert.equal(result, null); + assert.equal(documentLookupCount, 0); + assert.equal(acknowledgmentCreateCount, 0); + }); +}); diff --git a/backend/src/services/policy_acknowledgments.ts b/backend/src/services/policy_acknowledgments.ts index b7207bc..1e5ceb2 100644 --- a/backend/src/services/policy_acknowledgments.ts +++ b/backend/src/services/policy_acknowledgments.ts @@ -1,14 +1,21 @@ import db from '@/db/models'; import { withTransaction } from '@/db/with-transaction'; import ValidationError from '@/shared/errors/validation'; +import ForbiddenError from '@/shared/errors/forbidden'; import { assertAuthenticatedTenantUser, getCampusId, + getOwnTenant, getOrganizationIdOrGlobal, + hasFeaturePermission, + isActingInOwnScope, requireUserId, + tenantExactWhere, } from '@/services/shared/access'; import { tenantWhere } from '@/db/api/shared/repository'; import { resolvePagination } from '@/shared/constants/pagination'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { PolicyAcknowledgments } from '@/db/models/policy_acknowledgments'; import type { CurrentUser } from '@/db/api/types'; @@ -22,6 +29,20 @@ interface AcknowledgmentsFilter { page?: number | string; } +const ACKNOWLEDGMENT_REPORT_STAFF_ROLES = Object.freeze([ + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, +]); + +function assertCanAcknowledge(currentUser?: CurrentUser): void { + if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ACK_POLICY)) { + return; + } + throw new ForbiddenError(); +} + function requireDocumentId(value: unknown): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new ValidationError(); @@ -44,6 +65,51 @@ function toDto(record: PolicyAcknowledgments) { }; } +function assertCanReadReport(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + if (currentUser?.app_role?.globalAccess === true) { + if (currentUser?.activeScope) { + return; + } + throw new ForbiddenError(); + } + if ( + hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS, + ) + ) { + return; + } + throw new ForbiddenError(); +} + +function staffScopeWhere(currentUser?: CurrentUser) { + const tenant = getOwnTenant(currentUser); + const base = tenant.organizationId ? { organizationId: tenant.organizationId } : {}; + + if (tenant.level === 'school') { + return { ...base, schoolId: tenant.schoolId }; + } + if (tenant.level === 'campus') { + return { ...base, campusId: tenant.campusId }; + } + if (tenant.level === 'class') { + return { ...base, classId: tenant.classId }; + } + return base; +} + +function displayNameOf(user: { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +}): string { + return [user.firstName, user.lastName].filter(Boolean).join(' ').trim() + || user.email + || 'Staff Member'; +} + class PolicyAcknowledgmentsService { /** A campus staff member's own acknowledgments (optionally for one document). */ static async list( @@ -51,6 +117,7 @@ class PolicyAcknowledgmentsService { currentUser?: CurrentUser, ) { assertAuthenticatedTenantUser(currentUser); + assertCanAcknowledge(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); const organizationId = getOrganizationIdOrGlobal(currentUser); @@ -79,8 +146,13 @@ class PolicyAcknowledgmentsService { */ static async acknowledge(data: AcknowledgeInput, currentUser?: CurrentUser) { assertAuthenticatedTenantUser(currentUser); + assertCanAcknowledge(currentUser); const policyDocumentId = requireDocumentId(data.policyDocumentId); + if (!isActingInOwnScope(currentUser)) { + return null; + } + return withTransaction(async (transaction) => { // Resolve the document within the caller's tenant; its current version is // what gets acknowledged (the client cannot spoof an older version). @@ -120,6 +192,141 @@ class PolicyAcknowledgmentsService { return toDto(created); }); } + + static async report(currentUser?: CurrentUser) { + assertCanReadReport(currentUser); + const tenant = getOwnTenant(currentUser); + + const documents = await db.policy_documents.findAll({ + where: { + active: true, + ...tenantExactWhere(tenant), + }, + order: [ + ['category', 'asc'], + ['title', 'asc'], + ], + }); + + const staffUsers = await db.users.findAll({ + where: { + disabled: false, + ...staffScopeWhere(currentUser), + }, + include: [ + { + model: db.roles, + as: 'app_role', + required: true, + where: { name: ACKNOWLEDGMENT_REPORT_STAFF_ROLES }, + }, + ], + order: [ + ['lastName', 'asc'], + ['firstName', 'asc'], + ['email', 'asc'], + ], + }); + + const documentIds = documents.map((document) => document.id); + const userIds = staffUsers.map((user) => user.id); + + const acknowledgments = + documentIds.length > 0 && userIds.length > 0 + ? await db.policy_acknowledgments.findAll({ + where: { + policyDocumentId: documentIds, + userId: userIds, + }, + }) + : []; + + const acknowledgedAtByKey = new Map(); + for (const acknowledgment of acknowledgments) { + acknowledgedAtByKey.set( + `${acknowledgment.userId}:${acknowledgment.policyDocumentId}:${acknowledgment.version}`, + acknowledgment.acknowledgedAt, + ); + } + + const documentRows = documents.map((document) => { + let acknowledgedCount = 0; + for (const user of staffUsers) { + if (acknowledgedAtByKey.has(`${user.id}:${document.id}:${document.version}`)) { + acknowledgedCount += 1; + } + } + const missingCount = staffUsers.length - acknowledgedCount; + return { + id: document.id, + title: document.title, + category: document.category, + version: document.version, + totalStaff: staffUsers.length, + acknowledgedCount, + missingCount, + completionRate: staffUsers.length > 0 + ? Math.round((acknowledgedCount / staffUsers.length) * 100) + : 0, + }; + }); + + const staffRows = staffUsers.map((user) => { + const documentStatuses = documents.map((document) => { + const acknowledgedAt = acknowledgedAtByKey.get( + `${user.id}:${document.id}:${document.version}`, + ) ?? null; + return { + policyDocumentId: document.id, + title: document.title, + category: document.category, + version: document.version, + acknowledgedAt, + missing: acknowledgedAt === null, + }; + }); + const acknowledgedCount = documentStatuses.filter((status) => !status.missing).length; + const missingCount = documentStatuses.length - acknowledgedCount; + return { + userId: user.id, + name: displayNameOf(user), + email: user.email, + role: user.app_role?.name ?? null, + campusId: user.campusId ?? null, + schoolId: user.schoolId ?? null, + classId: user.classId ?? null, + acknowledgedCount, + missingCount, + completionRate: documentStatuses.length > 0 + ? Math.round((acknowledgedCount / documentStatuses.length) * 100) + : 0, + documents: documentStatuses, + }; + }); + + const totalRequired = documents.length * staffUsers.length; + const acknowledgedCount = staffRows.reduce( + (sum, staff) => sum + staff.acknowledgedCount, + 0, + ); + const missingCount = Math.max(totalRequired - acknowledgedCount, 0); + + return { + summary: { + scope: tenant.level, + totalDocuments: documents.length, + totalStaff: staffUsers.length, + totalRequired, + acknowledgedCount, + missingCount, + completionRate: totalRequired > 0 + ? Math.round((acknowledgedCount / totalRequired) * 100) + : 0, + }, + documents: documentRows, + staff: staffRows, + }; + } } export default PolicyAcknowledgmentsService; diff --git a/backend/src/services/refresh-token-maintenance.ts b/backend/src/services/refresh-token-maintenance.ts index 1984bd1..9863028 100644 --- a/backend/src/services/refresh-token-maintenance.ts +++ b/backend/src/services/refresh-token-maintenance.ts @@ -1,4 +1,5 @@ import config from '@/shared/config'; +import db from '@/db/models'; import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens'; /** @@ -45,3 +46,7 @@ export async function cleanupExpiredRefreshTokens(options: { return { cutoff, deleted }; } + +export async function closeRefreshTokenMaintenanceConnection(): Promise { + await db.sequelize.close(); +} diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index 120a907..f7a1b0d 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -7,11 +7,12 @@ import { getCampusId, assertAuthenticatedTenantUser, campusDimensionScope, - hasRoleAccess, + hasFeaturePermission, getDisplayName, + isActingInOwnScope, } from '@/services/shared/access'; import { ROLE_NAMES } from '@/shared/constants/roles'; -import { SAFETY_QUIZ_REPORT_ROLE_NAMES } from '@/shared/constants/safety-quiz'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { SafetyQuizResults } from '@/db/models/safety_quiz_results'; import type { CurrentUser } from '@/db/api/types'; @@ -90,7 +91,10 @@ class SafetyQuizResultsService { const result = await db.safety_quiz_results.findAndCountAll({ where: { ...orgFilter, - ...(hasRoleAccess(currentUser, SAFETY_QUIZ_REPORT_ROLE_NAMES) + ...(hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS, + ) ? campusDimensionScope(currentUser) : { userId: currentUser?.id ?? null }), ...(filter.week_of ? { week_of: filter.week_of } : {}), @@ -110,6 +114,10 @@ class SafetyQuizResultsService { assertAuthenticatedTenantUser(currentUser); assertValidResult(data); + if (!isActingInOwnScope(currentUser)) { + return null; + } + const organizationId = getOrganizationIdOrGlobal(currentUser); return withTransaction(async (transaction) => { diff --git a/backend/src/services/schools.ts b/backend/src/services/schools.ts new file mode 100644 index 0000000..6e8328a --- /dev/null +++ b/backend/src/services/schools.ts @@ -0,0 +1,123 @@ +import DbApi from '@/db/api/schools'; +import CampusesDBApi from '@/db/api/campuses'; +import { createCrudService } from '@/services/shared/crud-service'; +import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; +import { withTransaction } from '@/db/with-transaction'; +import { getRoleScope } from '@/services/shared/access'; +import { ROLE_SCOPES } from '@/shared/constants/roles'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import type { CurrentUser } from '@/db/api/types'; + +interface FirstCampusInput { + name?: string; + code?: string; + timezone?: string; + logo?: string | null; + address?: string | null; + phone?: string | null; + email?: string | null; +} + +/** Lowercase slug for an auto-generated campus code from its name. */ +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'campus' + ); +} + +/** + * School provisioning is permission-gated at the route and scope-gated here: + * only system/org scope can write school records. This keeps role names out of + * the guard while preventing school/campus users from creating sibling schools. + */ +function assertCanWriteSchools(currentUser?: CurrentUser): void { + const scope = getRoleScope(currentUser); + if (scope === ROLE_SCOPES.SYSTEM || scope === ROLE_SCOPES.ORGANIZATION) return; + throw new ForbiddenError(); +} + +const base = createCrudService(DbApi, { notFoundCode: 'schoolsNotFound' }); + +export default { + ...base, + async create(...args: Parameters) { + assertCanWriteSchools(args[1]); + return base.create(...args); + }, + async update(...args: Parameters) { + assertCanWriteSchools(args[2]); + return base.update(...args); + }, + async remove(...args: Parameters) { + assertCanWriteSchools(args[1]); + return base.remove(...args); + }, + async deleteByIds(...args: Parameters) { + assertCanWriteSchools(args[1]); + return base.deleteByIds(...args); + }, + + /** + * Atomically creates a school and its first campus (Organization → School → + * Campus). The create form captures the first campus's name + timezone; both + * rows are committed in one transaction (campus carries the new `schoolId`). + * Restricted to system/org scope users with the route permission. + */ + async createWithFirstCampus( + data: Parameters[0], + firstCampus: FirstCampusInput, + currentUser?: CurrentUser, + ) { + assertCanWriteSchools(currentUser); + + const campusName = firstCampus?.name; + const campusTimezone = firstCampus?.timezone; + if (!campusName || !campusTimezone) { + throw new ValidationError(); + } + + return withTransaction(async (transaction) => { + const school = await DbApi.create(data, { currentUser, transaction }); + const campus = await CampusesDBApi.create( + { + name: campusName, + code: firstCampus.code || slugify(campusName), + timezone: campusTimezone, + logo: firstCampus.logo ?? null, + address: firstCampus.address ?? null, + phone: firstCampus.phone ?? null, + email: firstCampus.email ?? null, + active: true, + // Inherit the (possibly explicitly-targeted) org from the new school so + // a global creator's first campus is not left org-less. + organization: school.organizationId ?? undefined, + }, + { currentUser, transaction }, + ); + await campus.setSchool(school.id, { transaction }); + + // Preset the new school's and first campus's default content (idempotent). + const organizationId = school.organizationId; + if (organizationId) { + await seedDefaultContentForTenant( + { level: 'school', organizationId, schoolId: school.id }, + transaction, + ); + await seedDefaultContentForTenant( + { level: 'campus', organizationId, campusId: campus.id }, + transaction, + ); + } + + return { + school: school.get({ plain: true }), + campus: campus.get({ plain: true }), + }; + }); + }, +}; diff --git a/backend/src/services/scope.ts b/backend/src/services/scope.ts new file mode 100644 index 0000000..5a25fb7 --- /dev/null +++ b/backend/src/services/scope.ts @@ -0,0 +1,278 @@ +import db from '@/db/models'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { ROLE_SCOPES } from '@/shared/constants/roles'; +import { + assertAuthenticatedTenantUser, + hasGlobalAccess, + getOrganizationId, + getRoleScope, + getSchoolId, + getCampusId, +} from '@/services/shared/access'; +import type { CurrentUser } from '@/db/api/types'; + +export type TenantChildLevel = 'organization' | 'school' | 'campus' | 'class'; + +interface TenantChild { + level: TenantChildLevel; + id: string; + name: string | null; + logo: string | null; + description?: string | null; + address?: string | null; + phone?: string | null; + email?: string | null; +} + +const UUID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function requireUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_RE.test(value)) { + throw new ForbiddenError(); + } + return value; +} + +/** Resolves a campus's owning school + org (for scope validation). */ +async function campusTenant( + campusId: string, +): Promise<{ organizationId: string | null; schoolId: string | null } | null> { + const campus = await db.campuses.findByPk(campusId, { + attributes: ['organizationId', 'schoolId'], + }); + return campus + ? { organizationId: campus.organizationId, schoolId: campus.schoolId } + : null; +} + +async function assertSchoolInScope( + schoolId: string, + currentUser?: CurrentUser, +): Promise { + if (hasGlobalAccess(currentUser)) return; + const scope = getRoleScope(currentUser); + if (scope === ROLE_SCOPES.SCHOOL) { + if (getSchoolId(currentUser) === schoolId) return; + throw new ForbiddenError(); + } + // organization tier: the school must belong to the user's org. + const school = await db.schools.findByPk(schoolId, { + attributes: ['organizationId'], + }); + if (school && school.organizationId === getOrganizationId(currentUser)) return; + throw new ForbiddenError(); +} + +async function assertCampusInScope( + campusId: string, + currentUser?: CurrentUser, +): Promise { + if (hasGlobalAccess(currentUser)) return; + const scope = getRoleScope(currentUser); + if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) { + if (getCampusId(currentUser) === campusId) return; + throw new ForbiddenError(); + } + const tenant = await campusTenant(campusId); + if (!tenant) throw new ForbiddenError(); + if (scope === ROLE_SCOPES.SCHOOL) { + if (tenant.schoolId === getSchoolId(currentUser)) return; + throw new ForbiddenError(); + } + if (tenant.organizationId === getOrganizationId(currentUser)) return; + throw new ForbiddenError(); +} + +function toChildren( + level: TenantChildLevel, + rows: Array<{ + id: string; + name: string | null; + logo?: string | null; + description?: string | null; + address?: string | null; + phone?: string | null; + email?: string | null; + }>, +): TenantChild[] { + return rows.map((r) => ({ + level, + id: r.id, + name: r.name, + logo: r.logo ?? null, + ...(r.description !== undefined ? { description: r.description } : {}), + ...(r.address !== undefined ? { address: r.address } : {}), + ...(r.phone !== undefined ? { phone: r.phone } : {}), + ...(r.email !== undefined ? { email: r.email } : {}), + })); +} + +class ScopeService { + /** + * The tenants the user can drill into below `parent` (or below their own + * tenant when no parent is given): org → schools → campuses → classes. + */ + static async listChildren( + parentLevel: unknown, + parentId: unknown, + limit: number | undefined, + currentUser?: CurrentUser, + ): Promise<{ rows: TenantChild[] }> { + assertAuthenticatedTenantUser(currentUser); + + const level = parentLevel ? String(parentLevel) : null; + const id = parentId ? requireUuid(parentId) : null; + const cleanLimit = + typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? Math.floor(limit) + : undefined; + + // Default: children of the user's own tenant level. + if (!level || !id) { + return { rows: await ScopeService.ownChildren(currentUser, cleanLimit) }; + } + + if (level === 'organization') { + if (!hasGlobalAccess(currentUser) && id !== getOrganizationId(currentUser)) { + throw new ForbiddenError(); + } + const schools = await db.schools.findAll({ + where: { organizationId: id }, + attributes: ['id', 'name', 'logo', 'description', 'address', 'phone', 'email'], + order: [['name', 'ASC']], + ...(cleanLimit ? { limit: cleanLimit } : {}), + }); + return { rows: toChildren('school', schools) }; + } + if (level === 'school') { + await assertSchoolInScope(id, currentUser); + const campuses = await db.campuses.findAll({ + where: { schoolId: id }, + attributes: ['id', 'name', 'logo', 'description', 'address', 'phone', 'email'], + order: [['name', 'ASC']], + ...(cleanLimit ? { limit: cleanLimit } : {}), + }); + return { rows: toChildren('campus', campuses) }; + } + if (level === 'campus') { + await assertCampusInScope(id, currentUser); + const classes = await db.classes.findAll({ + where: { campusId: id }, + attributes: ['id', 'name', 'logo'], + order: [['name', 'ASC']], + ...(cleanLimit ? { limit: cleanLimit } : {}), + }); + return { rows: toChildren('class', classes) }; + } + return { rows: [] }; + } + + /** + * Validates a requested drill-down target against the caller's scope and + * resolves its full tenant chain (org/school/campus/class ids). The result is + * attached to `currentUser.activeScope` by the active-scope middleware. + */ + static async resolveActiveScope( + level: string, + id: string, + currentUser?: CurrentUser, + ): Promise<{ + level: TenantChildLevel; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; + }> { + assertAuthenticatedTenantUser(currentUser); + const cleanId = requireUuid(id); + + if (level === 'organization') { + if ( + !hasGlobalAccess(currentUser) && + cleanId !== getOrganizationId(currentUser) + ) { + throw new ForbiddenError(); + } + return { level: 'organization', organizationId: cleanId, schoolId: null, campusId: null, classId: null }; + } + if (level === 'school') { + await assertSchoolInScope(cleanId, currentUser); + const school = await db.schools.findByPk(cleanId, { attributes: ['organizationId'] }); + if (!school) throw new ForbiddenError(); + return { level: 'school', organizationId: school.organizationId, schoolId: cleanId, campusId: null, classId: null }; + } + if (level === 'campus') { + await assertCampusInScope(cleanId, currentUser); + const tenant = await campusTenant(cleanId); + if (!tenant) throw new ForbiddenError(); + return { level: 'campus', organizationId: tenant.organizationId, schoolId: tenant.schoolId, campusId: cleanId, classId: null }; + } + if (level === 'class') { + const cls = await db.classes.findByPk(cleanId, { attributes: ['campusId', 'organizationId'] }); + if (!cls || !cls.campusId) throw new ForbiddenError(); + await assertCampusInScope(cls.campusId, currentUser); + const tenant = await campusTenant(cls.campusId); + return { + level: 'class', + organizationId: cls.organizationId ?? tenant?.organizationId ?? null, + schoolId: tenant?.schoolId ?? null, + campusId: cls.campusId, + classId: cleanId, + }; + } + throw new ForbiddenError(); + } + + private static async ownChildren( + currentUser?: CurrentUser, + limit?: number, + ): Promise { + const global = hasGlobalAccess(currentUser); + const orgId = getOrganizationId(currentUser); + const scope = getRoleScope(currentUser); + + if (global) { + const orgs = await db.organizations.findAll({ + attributes: ['id', 'name', 'logo'], + order: [['name', 'ASC']], + ...(limit ? { limit } : {}), + }); + return toChildren('organization', orgs); + } + if (scope === ROLE_SCOPES.ORGANIZATION && orgId) { + const schools = await db.schools.findAll({ + where: { organizationId: orgId }, + attributes: ['id', 'name', 'logo', 'description', 'address', 'phone', 'email'], + order: [['name', 'ASC']], + ...(limit ? { limit } : {}), + }); + return toChildren('school', schools); + } + if (scope === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + if (!schoolId) return []; + const campuses = await db.campuses.findAll({ + where: { schoolId }, + attributes: ['id', 'name', 'logo', 'description', 'address', 'phone', 'email'], + order: [['name', 'ASC']], + ...(limit ? { limit } : {}), + }); + return toChildren('campus', campuses); + } + if (scope === ROLE_SCOPES.CAMPUS) { + const campusId = getCampusId(currentUser); + if (!campusId) return []; + const classes = await db.classes.findAll({ + where: { campusId }, + attributes: ['id', 'name', 'logo'], + order: [['name', 'ASC']], + ...(limit ? { limit } : {}), + }); + return toChildren('class', classes); + } + return []; + } +} + +export default ScopeService; diff --git a/backend/src/services/search.ts b/backend/src/services/search.ts index a90b085..56b175c 100644 --- a/backend/src/services/search.ts +++ b/backend/src/services/search.ts @@ -18,7 +18,6 @@ const TABLE_COLUMNS: Record = { academic_years: ['name'], grades: ['name', 'code', 'description'], subjects: ['name', 'code', 'description'], - staff: ['employee_number', 'job_title'], classes: ['name', 'section'], timetables: ['name'], timetable_periods: ['room'], diff --git a/backend/src/services/shared/access.test.ts b/backend/src/services/shared/access.test.ts new file mode 100644 index 0000000..d7d03b0 --- /dev/null +++ b/backend/src/services/shared/access.test.ts @@ -0,0 +1,211 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hasFeaturePermission, isActingInOwnScope } from '@/services/shared/access'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import type { CurrentUser } from '@/db/api/types'; + +function permission(name: string) { + return { name }; +} + +function user(overrides: Partial): CurrentUser { + return { + id: 'user-1', + app_role: { name: ROLE_NAMES.TEACHER, globalAccess: false }, + ...overrides, + }; +} + +test('hasFeaturePermission allows a per-user custom grant', () => { + assert.equal( + hasFeaturePermission( + user({ custom_permissions: [permission('MANAGE_FRAME')] }), + 'MANAGE_FRAME', + ), + true, + ); +}); + +test('hasFeaturePermission allows a loaded role permission', () => { + assert.equal( + hasFeaturePermission( + user({ + app_role: { + name: ROLE_NAMES.TEACHER, + globalAccess: false, + permissions: [permission('MANAGE_FRAME')], + }, + }), + 'MANAGE_FRAME', + ), + true, + ); +}); + +test('hasFeaturePermission does not grant feature access from role name alone', () => { + assert.equal( + hasFeaturePermission( + user({ app_role: { name: ROLE_NAMES.DIRECTOR, globalAccess: false } }), + 'MANAGE_FRAME', + ), + false, + ); +}); + +test('hasFeaturePermission lets per-user exclusion override a role-granted permission', () => { + assert.equal( + hasFeaturePermission( + user({ + app_role: { + name: ROLE_NAMES.DIRECTOR, + globalAccess: false, + permissions: [permission('MANAGE_FRAME')], + }, + custom_permissions_filter: [permission('MANAGE_FRAME')], + }), + 'MANAGE_FRAME', + ), + false, + ); +}); + +test('hasFeaturePermission does not let globalAccess imply explicit personal workflow permissions', () => { + assert.equal( + hasFeaturePermission( + user({ app_role: { name: ROLE_NAMES.SUPER_ADMIN, globalAccess: true } }), + 'READ_PARENT_COMM', + ), + false, + ); + assert.equal( + hasFeaturePermission( + user({ + app_role: { name: ROLE_NAMES.SUPER_ADMIN, globalAccess: true }, + custom_permissions: [permission('READ_PARENT_COMM')], + }), + 'READ_PARENT_COMM', + ), + true, + ); +}); + +test('hasFeaturePermission does not let system_admin global scope imply permissions without grants', () => { + assert.equal( + hasFeaturePermission( + user({ app_role: { name: ROLE_NAMES.SYSTEM_ADMIN, globalAccess: true } }), + 'MANAGE_FRAME', + ), + false, + ); + assert.equal( + hasFeaturePermission( + user({ + app_role: { + name: ROLE_NAMES.SYSTEM_ADMIN, + globalAccess: true, + permissions: [permission('MANAGE_FRAME')], + }, + }), + 'MANAGE_FRAME', + ), + true, + ); +}); + +test('isActingInOwnScope allows a user without drilled scope', () => { + assert.equal( + isActingInOwnScope(user({ + organizationId: 'org-1', + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + }, + })), + true, + ); +}); + +test('isActingInOwnScope allows an active scope matching the user own scope', () => { + assert.equal( + isActingInOwnScope(user({ + organizationId: 'org-1', + schoolId: 'school-1', + app_role: { + name: ROLE_NAMES.PRINCIPAL, + scope: ROLE_SCOPES.SCHOOL, + globalAccess: false, + }, + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + })), + true, + ); +}); + +test('isActingInOwnScope rejects parent user drilled into a child scope', () => { + assert.equal( + isActingInOwnScope(user({ + organizationId: 'org-1', + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + }, + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + })), + false, + ); +}); + +test('isActingInOwnScope rejects organization users drilled into another organization', () => { + assert.equal( + isActingInOwnScope(user({ + organizationId: 'org-1', + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + }, + activeScope: { + level: 'organization', + organizationId: 'org-2', + schoolId: null, + campusId: null, + classId: null, + }, + })), + false, + ); +}); + +test('isActingInOwnScope rejects global access user drilled into a tenant', () => { + assert.equal( + isActingInOwnScope(user({ + app_role: { + name: ROLE_NAMES.SUPER_ADMIN, + scope: ROLE_SCOPES.SYSTEM, + globalAccess: true, + }, + activeScope: { + level: 'organization', + organizationId: 'org-1', + schoolId: null, + campusId: null, + classId: null, + }, + })), + false, + ); +}); diff --git a/backend/src/services/shared/access.ts b/backend/src/services/shared/access.ts index ffe120c..5076506 100644 --- a/backend/src/services/shared/access.ts +++ b/backend/src/services/shared/access.ts @@ -1,6 +1,7 @@ import { Op, literal } from 'sequelize'; import ForbiddenError from '@/shared/errors/forbidden'; -import { ROLE_SCOPES } from '@/shared/constants/roles'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { GLOBAL_BYPASS_EXCLUDED_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { CurrentUser } from '@/db/api/types'; /** UUIDs are inlined into a SQL literal subquery; reject anything that is not one. */ @@ -8,35 +9,49 @@ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; /** - * Shared tenant/role access helpers used by the feature services. Centralizes - * the (previously copy-pasted) organization/campus/role checks so every module + * Shared tenant/scope access helpers used by the feature services. Centralizes + * the (previously copy-pasted) organization/campus/scope checks so every module * resolves scope and access the same way. */ export function getOrganizationId(currentUser?: CurrentUser): string | null { + if (currentUser?.activeScope) { + return currentUser.activeScope.organizationId; + } return currentUser?.organizations?.id || currentUser?.organizationId || null; } export function getCampusId(currentUser?: CurrentUser): string | null { - const staff = currentUser?.staff_user; - if (Array.isArray(staff) && staff[0]?.campusId) { - return staff[0].campusId; + if (currentUser?.activeScope) { + return currentUser.activeScope.campusId; } return currentUser?.campusId || null; } export function getSchoolId(currentUser?: CurrentUser): string | null { - const staff = currentUser?.staff_user; - if (Array.isArray(staff) && staff[0]?.schoolId) { - return staff[0].schoolId; + if (currentUser?.activeScope) { + return currentUser.activeScope.schoolId; } return currentUser?.schoolId || null; } -/** The authorization scope of the user's role (`roles.scope`), if loaded. */ +export function getClassId(currentUser?: CurrentUser): string | null { + if (currentUser?.activeScope) { + return currentUser.activeScope.classId; + } + return currentUser?.classId || null; +} + +/** + * The active authorization scope (`roles.scope`), overridden by the drill-down + * `activeScope.level` when the user has drilled into a child tenant. + */ export function getRoleScope( currentUser?: CurrentUser, ): string | null | undefined { + if (currentUser?.activeScope) { + return currentUser.activeScope.level; + } return currentUser?.app_role?.scope; } @@ -46,6 +61,75 @@ export function getRoleName( return currentUser?.app_role?.name; } +function ownScopeKey(currentUser?: CurrentUser): string | null { + if (!currentUser?.id) { + return null; + } + + if (hasGlobalAccess(currentUser)) { + return 'system:global'; + } + + const scope = currentUser.app_role?.scope; + + if (scope === ROLE_SCOPES.CLASS) { + return currentUser.classId ? `class:${currentUser.classId}` : null; + } + + if (scope === ROLE_SCOPES.CAMPUS) { + return currentUser.campusId ? `campus:${currentUser.campusId}` : null; + } + + if (scope === ROLE_SCOPES.SCHOOL) { + return currentUser.schoolId ? `school:${currentUser.schoolId}` : null; + } + + if (scope === ROLE_SCOPES.ORGANIZATION) { + const organizationId = currentUser.organizations?.id || currentUser.organizationId || null; + return organizationId ? `organization:${organizationId}` : null; + } + + return null; +} + +function activeScopeKey(currentUser?: CurrentUser): string | null { + const activeScope = currentUser?.activeScope; + if (!activeScope) { + return null; + } + + if (activeScope.level === 'class') { + return activeScope.classId ? `class:${activeScope.classId}` : null; + } + + if (activeScope.level === 'campus') { + return activeScope.campusId ? `campus:${activeScope.campusId}` : null; + } + + if (activeScope.level === 'school') { + return activeScope.schoolId ? `school:${activeScope.schoolId}` : null; + } + + return activeScope.organizationId ? `organization:${activeScope.organizationId}` : null; +} + +/** + * Personal workflows may be completed only in the user's own scope. A parent + * user drilling into a child tenant is reading/working as that child scope, so + * personal quiz/check-in/acknowledgment mutations must not create reportable + * rows there. + */ +export function isActingInOwnScope(currentUser?: CurrentUser): boolean { + if (!currentUser?.activeScope) { + return true; + } + + const ownKey = ownScopeKey(currentUser); + const activeKey = activeScopeKey(currentUser); + + return Boolean(ownKey && activeKey && ownKey === activeKey); +} + /** Human-friendly name for the current user (full name, else email, else generic). */ export function getDisplayName(currentUser?: CurrentUser): string { const firstName = currentUser?.firstName || ''; @@ -64,11 +148,22 @@ export function requireOrganizationId(currentUser?: CurrentUser): string { return organizationId; } -/** True if the user has global access (e.g. SuperAdmin, superintendent). */ +/** + * True if the user has global access. While drilled into a tenant + * (`activeScope`), global access is suspended so the data is scoped to that + * tenant rather than spanning everything. + */ export function hasGlobalAccess(currentUser?: CurrentUser): boolean { + if (currentUser?.activeScope) { + return false; + } return currentUser?.app_role?.globalAccess === true; } +function hasPermissionBypass(currentUser?: CurrentUser): boolean { + return currentUser?.app_role?.name === ROLE_NAMES.SUPER_ADMIN; +} + /** * Returns organizationId for filtering, or null if user has global access. * Throws ForbiddenError if user has neither global access nor organizationId. @@ -103,31 +198,55 @@ export function assertAuthenticatedTenantUser(currentUser?: CurrentUser): void { throw new ForbiddenError(); } -/** True if the user has global access or holds one of the given role names. */ -export function hasRoleAccess( - currentUser: CurrentUser | undefined, - roleNames: readonly string[], -): boolean { - const roleName = getRoleName(currentUser); - return ( - currentUser?.app_role?.globalAccess === true || - roleNames.some((name) => name === roleName) - ); +function permissionNamesFrom(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((permission) => { + if ( + permission + && typeof permission === 'object' + && 'name' in permission + && typeof permission.name === 'string' + ) { + return permission.name; + } + return null; + }) + .filter((name): name is string => name !== null); } /** - * Restricts users who are not tenant-wide to their own campus. Tenant-wide roles - * (or global access) see everything. + * Permission-aware feature gate for service-level checks. Per-user exclusions + * win over role permissions; per-user grants can add a permission without + * changing the user's role. */ -export function campusScope( +export function hasFeaturePermission( currentUser: CurrentUser | undefined, - tenantWideRoleNames: readonly string[], -): { campusId?: string } { - if (hasRoleAccess(currentUser, tenantWideRoleNames)) { - return {}; + permission: string, +): boolean { + if ( + hasPermissionBypass(currentUser) + && !GLOBAL_BYPASS_EXCLUDED_PERMISSIONS.some((name) => name === permission) + ) { + return true; } - const campusId = getCampusId(currentUser); - return campusId ? { campusId } : {}; + + if (permissionNamesFrom(currentUser?.custom_permissions_filter).includes(permission)) { + return false; + } + + if (permissionNamesFrom(currentUser?.custom_permissions).includes(permission)) { + return true; + } + + const rolePermissions = permissionNamesFrom(currentUser?.app_role?.permissions); + if (rolePermissions.includes(permission)) { + return true; + } + + return false; } /** Campus-dimension constraint for a campus-bearing table, keyed by scope. */ @@ -175,7 +294,10 @@ export function campusDimensionScope( }; } - if (scope === ROLE_SCOPES.CAMPUS) { + // Class-scoped roles (Teacher/Support Staff) still resolve to their campus for + // campus-keyed tables (which have no class granularity); class-level data uses + // classDimensionScope instead. + if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) { const campusId = getCampusId(currentUser); return campusId ? { campusId } : {}; } @@ -183,3 +305,80 @@ export function campusDimensionScope( // organization / external / guest: no campus-dimension narrowing here. return {}; } + +// Per-tenant content helpers live in the layer-neutral `shared/tenancy` so both +// the data layer and services can use them; re-exported here for service callers. +export { + getOwnTenant, + tenantExactWhere, + tenantStamp, + type ActiveTenant, + type TenantLevel, +} from '@/shared/tenancy'; + +/** Class-dimension constraint for a class-keyed table, keyed by scope. */ +export type ClassDimensionScope = { + classId?: string | { [Op.in]: ReturnType }; +}; + +/** + * The class-dimension filter for the current user, for tables that carry a + * `classId` (class-level content, class attendance). ANDs with the organization + * filter the caller applies: + * + * - global / organization scope → `{}` (no class narrowing) + * - school scope → classes in the user's school (campus IN school) + * - campus scope → classes in the user's campus + * - class scope (Teacher/Support Staff) → `{ classId }` + */ +export function classDimensionScope( + currentUser?: CurrentUser, +): ClassDimensionScope { + if (hasGlobalAccess(currentUser)) { + return {}; + } + + const scope = getRoleScope(currentUser); + + if (scope === ROLE_SCOPES.CLASS) { + const classId = getClassId(currentUser); + return classId ? { classId } : {}; + } + + if (scope === ROLE_SCOPES.CAMPUS) { + const campusId = getCampusId(currentUser); + if (!campusId) { + return {}; + } + if (!UUID_RE.test(campusId)) { + throw new ForbiddenError(); + } + return { + classId: { + [Op.in]: literal( + `(SELECT "id" FROM "classes" WHERE "campusId" = '${campusId}' AND "deletedAt" IS NULL)`, + ), + }, + }; + } + + if (scope === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + if (!schoolId) { + return {}; + } + if (!UUID_RE.test(schoolId)) { + throw new ForbiddenError(); + } + return { + classId: { + [Op.in]: literal( + `(SELECT "c"."id" FROM "classes" "c" JOIN "campuses" "cm" ON "cm"."id" = "c"."campusId" WHERE "cm"."schoolId" = '${schoolId}' AND "c"."deletedAt" IS NULL AND "cm"."deletedAt" IS NULL)`, + ), + }, + }; + } + + // organization / external / guest: no class-dimension narrowing here. + return {}; +} diff --git a/backend/src/services/shared/role-policy.test.ts b/backend/src/services/shared/role-policy.test.ts index 1bbb45e..47adac9 100644 --- a/backend/src/services/shared/role-policy.test.ts +++ b/backend/src/services/shared/role-policy.test.ts @@ -1,7 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + canAssignUserRole, + canCreateUserWithRole, + canDeleteUserWithRole, canManageUserWithRole, + canUpdateUserWithRole, canDeleteOrganization, } from '@/services/shared/role-policy'; import { ROLE_NAMES } from '@/shared/constants/roles'; @@ -13,10 +17,27 @@ function actor(role: string): CurrentUser { // --- user-management relational constraints (§3.3) --- -test('super_admin can manage every role', () => { - for (const role of Object.values(ROLE_NAMES)) { +test('super_admin can manage every assignable role except creating another super_admin', () => { + for (const role of Object.values(ROLE_NAMES).filter((item) => + item !== ROLE_NAMES.GUEST && item !== ROLE_NAMES.SUPER_ADMIN + )) { assert.equal(canManageUserWithRole(actor(ROLE_NAMES.SUPER_ADMIN), role), true); + assert.equal(canCreateUserWithRole(actor(ROLE_NAMES.SUPER_ADMIN), role), true); } + assert.equal(canCreateUserWithRole(actor(ROLE_NAMES.SUPER_ADMIN), ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(canManageUserWithRole(actor(ROLE_NAMES.SUPER_ADMIN), ROLE_NAMES.GUEST), false); +}); + +test('super_admin account is update-only', () => { + const superAdmin = actor(ROLE_NAMES.SUPER_ADMIN); + const systemAdmin = actor(ROLE_NAMES.SYSTEM_ADMIN); + + assert.equal(canUpdateUserWithRole(superAdmin, ROLE_NAMES.SUPER_ADMIN), true); + assert.equal(canUpdateUserWithRole(systemAdmin, ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(canDeleteUserWithRole(superAdmin, ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(canAssignUserRole(superAdmin, ROLE_NAMES.OWNER, ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(canAssignUserRole(superAdmin, ROLE_NAMES.SUPER_ADMIN, ROLE_NAMES.OWNER), false); + assert.equal(canAssignUserRole(superAdmin, ROLE_NAMES.SUPER_ADMIN, ROLE_NAMES.SUPER_ADMIN), true); }); test('system_admin cannot manage super_admin or system_admin', () => { @@ -43,6 +64,31 @@ test('director manages campus/external roles but not director or above', () => { assert.equal(canManageUserWithRole(dir, ROLE_NAMES.STUDENT), true); }); +test('owner and superintendent manage the school-tier roles', () => { + for (const boss of [ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT]) { + assert.equal(canManageUserWithRole(actor(boss), ROLE_NAMES.PRINCIPAL), true); + assert.equal(canManageUserWithRole(actor(boss), ROLE_NAMES.REGISTRAR), true); + } +}); + +test('principal manages the school staff but not org/system roles or another principal', () => { + const principal = actor(ROLE_NAMES.PRINCIPAL); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.REGISTRAR), true); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.DIRECTOR), true); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.TEACHER), true); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.STUDENT), true); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.PRINCIPAL), false); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.SUPERINTENDENT), false); + assert.equal(canManageUserWithRole(principal, ROLE_NAMES.OWNER), false); +}); + +test('registrar (read-only) manages nobody', () => { + const registrar = actor(ROLE_NAMES.REGISTRAR); + for (const role of Object.values(ROLE_NAMES)) { + assert.equal(canManageUserWithRole(registrar, role), false); + } +}); + test('campus staff (e.g. teacher) cannot manage any user', () => { const teacher = actor(ROLE_NAMES.TEACHER); for (const role of Object.values(ROLE_NAMES)) { diff --git a/backend/src/services/shared/role-policy.ts b/backend/src/services/shared/role-policy.ts index 86ed3a7..58fb658 100644 --- a/backend/src/services/shared/role-policy.ts +++ b/backend/src/services/shared/role-policy.ts @@ -10,11 +10,13 @@ import type { CurrentUser } from '@/db/api/types'; * enforced separately in the data layer. */ -const ALL_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES); +const ASSIGNABLE_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES).filter( + (role) => role !== ROLE_NAMES.GUEST && role !== ROLE_NAMES.SUPER_ADMIN, +); /** * The roles each actor may create, re-assign, or delete on another user: - * - `super_admin`: anyone. + * - `super_admin`: any assignable user role (`guest` is public fallback, not a user target). * - `system_admin`: anyone except `super_admin` / `system_admin`. * - `owner`: org/school/campus/external roles (not the system roles, not another owner). * - `superintendent`: not `owner` / `superintendent` (per spec). @@ -25,33 +27,32 @@ const ALL_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES); * - everyone else: nobody. */ const MANAGEABLE_ROLES_BY_ACTOR: Record = { - [ROLE_NAMES.SUPER_ADMIN]: ALL_ROLE_NAMES, + [ROLE_NAMES.SUPER_ADMIN]: ASSIGNABLE_ROLE_NAMES, [ROLE_NAMES.SYSTEM_ADMIN]: [ ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.PRINCIPAL, ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, - ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, + ROLE_NAMES.GUARDIAN, ], [ROLE_NAMES.OWNER]: [ ROLE_NAMES.SUPERINTENDENT, ROLE_NAMES.PRINCIPAL, ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, - ROLE_NAMES.GUEST, ], [ROLE_NAMES.SUPERINTENDENT]: [ ROLE_NAMES.PRINCIPAL, ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, - ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, + ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ], [ROLE_NAMES.PRINCIPAL]: [ ROLE_NAMES.REGISTRAR, ROLE_NAMES.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, - ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, + ROLE_NAMES.GUARDIAN, ], [ROLE_NAMES.REGISTRAR]: [], [ROLE_NAMES.DIRECTOR]: [ ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, ROLE_NAMES.SUPPORT_STAFF, - ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ROLE_NAMES.GUEST, + ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, ], [ROLE_NAMES.OFFICE_MANAGER]: [], [ROLE_NAMES.TEACHER]: [], @@ -69,7 +70,7 @@ const ORGANIZATION_DELETE_ROLES: readonly RoleName[] = [ ]; export function isRoleName(value: string | null | undefined): value is RoleName { - return value != null && (ALL_ROLE_NAMES as readonly string[]).includes(value); + return value != null && (Object.values(ROLE_NAMES) as readonly string[]).includes(value); } function actorRoleName(currentUser?: CurrentUser): RoleName | null { @@ -78,7 +79,7 @@ function actorRoleName(currentUser?: CurrentUser): RoleName | null { } /** - * Whether `currentUser` may create/re-assign/delete a user holding `targetRole`. + * Whether `currentUser` may manage a normal user holding `targetRole`. * A `null`/unknown target role is treated as below every role the actor manages * (so a manager may act on a not-yet-assigned user), but an actor that manages * nobody is always denied. @@ -95,6 +96,14 @@ export function canManageUserWithRole( return manageable.includes(targetRole); } +export function manageableRoleNamesFor( + currentUser: CurrentUser | undefined, +): readonly RoleName[] { + const actor = actorRoleName(currentUser); + if (!actor) return []; + return MANAGEABLE_ROLES_BY_ACTOR[actor]; +} + export function assertCanManageUserWithRole( currentUser: CurrentUser | undefined, targetRole: string | null | undefined, @@ -104,6 +113,82 @@ export function assertCanManageUserWithRole( } } +export function canCreateUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): boolean { + if (targetRole === ROLE_NAMES.SUPER_ADMIN) return false; + return canManageUserWithRole(currentUser, targetRole); +} + +export function canUpdateUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): boolean { + if (targetRole === ROLE_NAMES.SUPER_ADMIN) { + return actorRoleName(currentUser) === ROLE_NAMES.SUPER_ADMIN; + } + return canManageUserWithRole(currentUser, targetRole); +} + +export function canAssignUserRole( + currentUser: CurrentUser | undefined, + currentRole: string | null | undefined, + nextRole: string | null | undefined, +): boolean { + if (nextRole === ROLE_NAMES.SUPER_ADMIN) { + return currentRole === ROLE_NAMES.SUPER_ADMIN + && actorRoleName(currentUser) === ROLE_NAMES.SUPER_ADMIN; + } + if (currentRole === ROLE_NAMES.SUPER_ADMIN) return false; + return canManageUserWithRole(currentUser, nextRole); +} + +export function canDeleteUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): boolean { + if (targetRole === ROLE_NAMES.SUPER_ADMIN) return false; + return canManageUserWithRole(currentUser, targetRole); +} + +export function assertCanCreateUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): void { + if (!canCreateUserWithRole(currentUser, targetRole)) { + throw new ForbiddenError(); + } +} + +export function assertCanUpdateUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): void { + if (!canUpdateUserWithRole(currentUser, targetRole)) { + throw new ForbiddenError(); + } +} + +export function assertCanAssignUserRole( + currentUser: CurrentUser | undefined, + currentRole: string | null | undefined, + nextRole: string | null | undefined, +): void { + if (!canAssignUserRole(currentUser, currentRole, nextRole)) { + throw new ForbiddenError(); + } +} + +export function assertCanDeleteUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): void { + if (!canDeleteUserWithRole(currentUser, targetRole)) { + throw new ForbiddenError(); + } +} + export function canDeleteOrganization(currentUser?: CurrentUser): boolean { const actor = actorRoleName(currentUser); return actor !== null && ORGANIZATION_DELETE_ROLES.includes(actor); diff --git a/backend/src/services/staff.ts b/backend/src/services/staff.ts deleted file mode 100644 index 40328a3..0000000 --- a/backend/src/services/staff.ts +++ /dev/null @@ -1,44 +0,0 @@ -import DbApi from '@/db/api/staff'; -import { createCrudService } from '@/services/shared/crud-service'; -import { assertCanManageUserWithRole } from '@/services/shared/role-policy'; -import { STAFF_TYPE_TO_ROLE_NAME } from '@/shared/constants/roles'; -import { isRecord } from '@/shared/object'; -import type { CurrentUser } from '@/db/api/types'; - -const base = createCrudService(DbApi, { notFoundCode: 'staffNotFound' }); - -/** - * Relational policy on staff writes (Workstream 3 §3.3): a staff profile carries - * a `staff_type` that maps to a campus role, so creating/updating one is - * effectively managing a user with that role. Enforce the same role-hierarchy - * rule used for the `users` write paths, so an actor cannot provision a staff - * member of a role it is not allowed to manage (e.g. via a stray - * `custom_permission` granting `CREATE_STAFF`). Tenant/campus scoping stays in - * the data layer. - */ -function assertCanManageStaffRole( - currentUser: CurrentUser | undefined, - data: unknown, -): void { - if (!isRecord(data) || typeof data.staff_type !== 'string') { - return; - } - const role = STAFF_TYPE_TO_ROLE_NAME[data.staff_type]; - if (role) { - assertCanManageUserWithRole(currentUser, role); - } -} - -const service: typeof base = { - ...base, - create(data, currentUser) { - assertCanManageStaffRole(currentUser, data); - return base.create(data, currentUser); - }, - update(data, id, currentUser) { - assertCanManageStaffRole(currentUser, data); - return base.update(data, id, currentUser); - }, -}; - -export default service; diff --git a/backend/src/services/staff_attendance.test.ts b/backend/src/services/staff_attendance.test.ts new file mode 100644 index 0000000..6f623e6 --- /dev/null +++ b/backend/src/services/staff_attendance.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Op } from 'sequelize'; + +import db from '@/db/models'; +import StaffAttendanceService from '@/services/staff_attendance'; +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; +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('StaffAttendanceService', () => { + test('includes school-owned users and school campuses in school report scope', async () => { + const schoolUser = createTestUser({ + organizationId: '11111111-1111-4111-8111-111111111111', + organizations: { id: '11111111-1111-4111-8111-111111111111' }, + schoolId: '22222222-2222-4222-8222-222222222222', + campusId: null, + app_role: { + name: ROLE_NAMES.REGISTRAR, + scope: ROLE_SCOPES.SCHOOL, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS)], + }, + }); + let capturedWhere: unknown = null; + + mock.method(db.staff_attendance_records, 'findAndCountAll', async (options: unknown) => { + if (isRecord(options)) { + capturedWhere = options.where; + } + return { rows: [], count: 0 }; + }); + + await StaffAttendanceService.listRecords({}, schoolUser); + + assert.equal(isRecord(capturedWhere), true); + if (!isRecord(capturedWhere)) { + return; + } + assert.equal(Object.getOwnPropertySymbols(capturedWhere).includes(Op.or), true); + }); + + test('upserts a school office staff attendance record inside school scope', async () => { + const organizationId = '11111111-1111-4111-8111-111111111111'; + const schoolId = '22222222-2222-4222-8222-222222222222'; + const actor = createTestUser({ + id: '44444444-4444-4444-8444-444444444444', + organizationId, + organizations: { id: organizationId }, + schoolId, + campusId: null, + app_role: { + name: ROLE_NAMES.PRINCIPAL, + scope: ROLE_SCOPES.SCHOOL, + globalAccess: false, + permissions: [permission(FEATURE_PERMISSIONS.FILL_ATTENDANCE)], + }, + }); + let userWhere: unknown = null; + + mock.method(db.users, 'findOne', async (options: unknown) => { + if (isRecord(options)) { + userWhere = options.where; + } + return { + get: () => ({ + id: '55555555-5555-4555-8555-555555555555', + firstName: 'Nicole', + lastName: 'Adams', + email: 'registrar@flatlogic.com', + campusId: null, + app_role: { name: ROLE_NAMES.REGISTRAR }, + }), + }; + }); + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(db.staff_attendance_records, 'findOne', async () => null); + mock.method(db.staff_attendance_records, 'create', async (payload: unknown) => ({ + get: () => ({ + id: '66666666-6666-4666-8666-666666666666', + ...(isRecord(payload) ? payload : {}), + createdAt: new Date('2026-06-17T12:00:00Z'), + updatedAt: new Date('2026-06-17T12:00:00Z'), + }), + })); + + await StaffAttendanceService.upsertRecord( + { + userId: '55555555-5555-4555-8555-555555555555', + date: '2026-06-17', + status: 'present', + note: '', + }, + actor, + ); + + assert.equal(isRecord(userWhere), true); + if (!isRecord(userWhere)) { + return; + } + assert.equal(userWhere.schoolId, schoolId); + assert.equal(userWhere.campusId, null); + }); +}); diff --git a/backend/src/services/staff_attendance.ts b/backend/src/services/staff_attendance.ts index e0a51b5..9a25fcd 100644 --- a/backend/src/services/staff_attendance.ts +++ b/backend/src/services/staff_attendance.ts @@ -1,20 +1,26 @@ -import { clampLimit, optionalIsoDate } from '@/services/shared/validate'; -import { Op } from 'sequelize'; +import { clampLimit, optionalIsoDate, requiredIsoDate } from '@/services/shared/validate'; +import { Op, literal, type WhereOptions } from 'sequelize'; import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; import { STAFF_ATTENDANCE_DEFAULT_LIMIT, STAFF_ATTENDANCE_MAX_LIMIT, - STAFF_ATTENDANCE_REPORT_ROLE_NAMES, STAFF_ATTENDANCE_STATUSES, } from '@/shared/constants/staff-attendance'; -import { STAFF_STATUSES } from '@/shared/constants/staff'; import { assertAuthenticatedTenantUser, campusDimensionScope, - hasRoleAccess, + getCampusId, + getRoleScope, + getSchoolId, + hasFeaturePermission, requireOrganizationId, requireUserId, } from '@/services/shared/access'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { StaffAttendanceRecords } from '@/db/models/staff_attendance_records'; import type { CurrentUser } from '@/db/api/types'; @@ -24,16 +30,96 @@ interface StaffAttendanceFilter { limit?: unknown; } +interface StaffAttendanceInput { + date?: unknown; + status?: unknown; + note?: unknown; + userId?: unknown; +} + +const STAFF_ATTENDANCE_INTERNAL_ROLE_NAMES = [ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, +] as const; +const STAFF_ATTENDANCE_STATUS_VALUES: readonly string[] = Object.values(STAFF_ATTENDANCE_STATUSES); + +const STAFF_ATTENDANCE_UUID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function schoolCampusIdSubquery(schoolId: string) { + if (!STAFF_ATTENDANCE_UUID_RE.test(schoolId)) { + return null; + } + + return literal( + `(SELECT "id" FROM "campuses" WHERE "schoolId" = '${schoolId}' AND "deletedAt" IS NULL)`, + ); +} + +function schoolUserIdSubquery(schoolId: string) { + if (!STAFF_ATTENDANCE_UUID_RE.test(schoolId)) { + return null; + } + + return literal( + `(SELECT "id" FROM "users" WHERE "schoolId" = '${schoolId}' AND "deletedAt" IS NULL)`, + ); +} + /** * Restricts records to the staff member, or (for report roles) to the records * their scope allows: org-wide for owner/superintendent, the school's campuses * for principal/registrar, a single campus for director. */ function visibilityScope(currentUser?: CurrentUser) { - if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) { + if ( + !hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS, + ) + ) { return { userId: requireUserId(currentUser) }; } + if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + const campusSubquery = schoolId ? schoolCampusIdSubquery(schoolId) : null; + const userSubquery = schoolId ? schoolUserIdSubquery(schoolId) : null; + + if (campusSubquery && userSubquery) { + return { + [Op.or]: [ + { campusId: { [Op.in]: campusSubquery } }, + { userId: { [Op.in]: userSubquery } }, + ], + }; + } + } + + return campusDimensionScope(currentUser); +} + +function staffCountScope(currentUser?: CurrentUser): WhereOptions { + if (getRoleScope(currentUser) === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + const campusSubquery = schoolId ? schoolCampusIdSubquery(schoolId) : null; + + if (campusSubquery) { + return { + [Op.or]: [ + { campusId: { [Op.in]: campusSubquery } }, + { schoolId }, + ], + }; + } + } + return campusDimensionScope(currentUser); } @@ -73,6 +159,75 @@ function toRecordDto(record: StaffAttendanceRecords) { }; } +function assertCanFillStaffAttendance(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + + if (hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.FILL_ATTENDANCE)) { + return; + } + + throw new ForbiddenError(); +} + +function validateStatus(value: unknown): string { + if ( + typeof value === 'string' + && STAFF_ATTENDANCE_STATUS_VALUES.includes(value) + ) { + return value; + } + + throw new ValidationError(); +} + +function validateNote(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + return value.trim(); +} + +function requireString(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(); + } + + return value.trim(); +} + +function staffUserScope(currentUser?: CurrentUser): WhereOptions { + const scope = getRoleScope(currentUser); + const base = { organizationId: requireOrganizationId(currentUser) }; + + if (scope === ROLE_SCOPES.SCHOOL) { + const schoolId = getSchoolId(currentUser); + if (!schoolId) { + throw new ForbiddenError(); + } + return { ...base, schoolId, campusId: null }; + } + + if (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS) { + const campusId = getCampusId(currentUser); + if (!campusId) { + throw new ForbiddenError(); + } + return { ...base, campusId }; + } + + return { ...base, schoolId: null, campusId: null }; +} + +function staffUserName(user: { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +}): string { + const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); + return fullName || user.email || 'Staff Member'; +} + class StaffAttendanceService { static async listRecords( filter: StaffAttendanceFilter, @@ -124,12 +279,21 @@ class StaffAttendanceService { db.staff_attendance_records.count({ where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.ABSENT }, }), - db.staff.count({ + db.users.count({ where: { organizationId: requireOrganizationId(currentUser), - status: STAFF_STATUSES.ACTIVE, - ...campusDimensionScope(currentUser), + ...staffCountScope(currentUser), }, + include: [ + { + model: db.roles, + as: 'app_role', + required: true, + where: { + name: { [Op.in]: STAFF_ATTENDANCE_INTERNAL_ROLE_NAMES }, + }, + }, + ], }), ]); @@ -141,6 +305,75 @@ class StaffAttendanceService { absent, }; } + + static async upsertRecord( + data: StaffAttendanceInput, + currentUser?: CurrentUser, + ) { + assertCanFillStaffAttendance(currentUser); + + const userId = requireString(data.userId); + const attendanceDate = requiredIsoDate(data.date); + const status = validateStatus(data.status); + const note = validateNote(data.note); + + const staffUser = await db.users.findOne({ + where: { + id: userId, + ...staffUserScope(currentUser), + }, + include: [ + { + model: db.roles, + as: 'app_role', + attributes: ['name'], + }, + ], + }); + + if (!staffUser) { + throw new ForbiddenError(); + } + + const plain = staffUser.get({ plain: true }) as { + id: string; + firstName?: string | null; + lastName?: string | null; + email?: string | null; + campusId?: string | null; + app_role?: { name?: string | null } | null; + }; + + return withTransaction(async (transaction) => { + const existing = await db.staff_attendance_records.findOne({ + where: { + organizationId: requireOrganizationId(currentUser), + userId, + attendance_date: attendanceDate, + }, + transaction, + }); + const payload = { + attendance_date: attendanceDate, + status, + note, + user_name: staffUserName(plain), + user_role: plain.app_role?.name ?? null, + organizationId: requireOrganizationId(currentUser), + campusId: plain.campusId ?? null, + userId, + updatedById: requireUserId(currentUser), + }; + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.staff_attendance_records.create( + { ...payload, createdById: requireUserId(currentUser) }, + { transaction }, + ); + + return toRecordDto(saved); + }); + } } export default StaffAttendanceService; diff --git a/backend/src/services/user_progress.test.ts b/backend/src/services/user_progress.test.ts new file mode 100644 index 0000000..6f759f4 --- /dev/null +++ b/backend/src/services/user_progress.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import UserProgressService from '@/services/user_progress'; +import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress'; +import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles'; +import { createTestUser } from '@/test-utils'; +import type { CurrentUser } from '@/db/api/types'; + +afterEach(() => { + mock.restoreAll(); +}); + +function parentUserInChildScope(): CurrentUser { + return createTestUser({ + organizationId: 'org-1', + organizations: { id: 'org-1' }, + schoolId: null, + campusId: null, + app_role: { + name: ROLE_NAMES.SUPERINTENDENT, + scope: ROLE_SCOPES.ORGANIZATION, + globalAccess: false, + permissions: [], + }, + activeScope: { + level: 'school', + organizationId: 'org-1', + schoolId: 'school-1', + campusId: null, + classId: null, + }, + }); +} + +describe('UserProgressService drilled scope persistence guard', () => { + test('upserts classroom strategy favorites as user progress', async () => { + const actor = createTestUser({ + id: 'user-1', + organizationId: 'org-1', + organizations: { id: 'org-1' }, + campusId: 'campus-1', + app_role: { + name: ROLE_NAMES.DIRECTOR, + scope: ROLE_SCOPES.CAMPUS, + globalAccess: false, + permissions: [], + }, + }); + let createdPayload: Record | null = null; + + mock.method(db.sequelize, 'transaction', async () => ({ + commit: async () => undefined, + rollback: async () => undefined, + })); + mock.method(db.user_progress, 'findOne', async () => null); + mock.method(db.user_progress, 'create', async (payload: Record) => { + createdPayload = payload; + return { + get: () => ({ + id: 'progress-1', + ...payload, + createdAt: new Date('2026-06-17T12:00:00Z'), + updatedAt: new Date('2026-06-17T12:00:00Z'), + }), + }; + }); + + const result = await UserProgressService.upsert( + { + progress_type: USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE, + item_id: 'token-economy', + value: 'Token Economy System', + }, + actor, + ); + + assert.notEqual(createdPayload, null); + const payload = createdPayload as Record; + assert.equal(payload.progress_type, USER_PROGRESS_TYPES.CLASSROOM_STRATEGY_FAVORITE); + assert.equal(payload.item_id, 'token-economy'); + assert.equal(payload.value, 'Token Economy System'); + assert.equal(payload.organizationId, 'org-1'); + assert.equal(payload.campusId, 'campus-1'); + assert.equal(payload.userId, 'user-1'); + assert.equal(result?.item_id, 'token-economy'); + }); + + test('does not list own progress while a parent user is drilled into a child scope', async () => { + let listCount = 0; + mock.method(db.user_progress, 'findAndCountAll', (async () => { + listCount += 1; + return { rows: [], count: 0 }; + }) as unknown as typeof db.user_progress.findAndCountAll); + + const result = await UserProgressService.list( + { progress_type: USER_PROGRESS_TYPES.SIGN_LEARNED }, + parentUserInChildScope(), + ); + + assert.deepEqual(result, { rows: [], count: 0 }); + assert.equal(listCount, 0); + }); + + test('does not upsert progress while a parent user is drilled into a child scope', async () => { + let createCount = 0; + mock.method(db.user_progress, 'create', (async () => { + createCount += 1; + return null; + }) as unknown as typeof db.user_progress.create); + + const result = await UserProgressService.upsert( + { + progress_type: USER_PROGRESS_TYPES.SIGN_LEARNED, + item_id: 'help', + value: 'Help', + }, + parentUserInChildScope(), + ); + + assert.equal(result, null); + assert.equal(createCount, 0); + }); + + test('does not delete progress while a parent user is drilled into a child scope', async () => { + let deleteCount = 0; + mock.method(db.user_progress, 'destroy', (async () => { + deleteCount += 1; + return 1; + }) as unknown as typeof db.user_progress.destroy); + + const result = await UserProgressService.removeByItem( + { progress_type: USER_PROGRESS_TYPES.SIGN_LEARNED, item_id: 'help' }, + parentUserInChildScope(), + ); + + assert.deepEqual(result, { deletedCount: 0 }); + assert.equal(deleteCount, 0); + }); +}); diff --git a/backend/src/services/user_progress.ts b/backend/src/services/user_progress.ts index 3176642..85414b8 100644 --- a/backend/src/services/user_progress.ts +++ b/backend/src/services/user_progress.ts @@ -5,6 +5,7 @@ import { assertAuthenticatedTenantUser, getCampusId, getOrganizationIdOrGlobal, + isActingInOwnScope, requireUserId, } from '@/services/shared/access'; import { @@ -74,6 +75,10 @@ class UserProgressService { const progressType = assertValidProgressType(filter.progress_type); const { limit, offset } = resolvePagination(filter.limit, filter.page); + if (!isActingInOwnScope(currentUser)) { + return { rows: [], count: 0 }; + } + const organizationId = getOrganizationIdOrGlobal(currentUser); const orgFilter = organizationId ? { organizationId } : {}; @@ -99,6 +104,10 @@ class UserProgressService { assertAuthenticatedTenantUser(currentUser); assertValidMutation(data); + if (!isActingInOwnScope(currentUser)) { + return null; + } + const organizationId = getOrganizationIdOrGlobal(currentUser); const orgFilter = organizationId ? { organizationId } : {}; const progressType = assertValidProgressType(data.progress_type); @@ -151,6 +160,10 @@ class UserProgressService { assertAuthenticatedTenantUser(currentUser); const progressType = assertValidProgressType(filter.progress_type); + if (!isActingInOwnScope(currentUser)) { + return { deletedCount: 0 }; + } + if ( typeof filter.item_id !== 'string' || filter.item_id.trim().length === 0 diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts index 83fd876..a9eda3d 100644 --- a/backend/src/services/users.ts +++ b/backend/src/services/users.ts @@ -1,14 +1,20 @@ import { PassThrough } from 'stream'; import csv from 'csv-parser'; +import type { Transaction } from 'sequelize'; import db from '@/db/models'; import UsersDBApi from '@/db/api/users'; import OrganizationsDBApi from '@/db/api/organizations'; import ValidationError from '@/shared/errors/validation'; import AuthService from '@/services/auth'; -import { assertCanManageUserWithRole } from '@/services/shared/role-policy'; +import { + assertCanAssignUserRole, + assertCanCreateUserWithRole, + assertCanDeleteUserWithRole, + assertCanUpdateUserWithRole, +} from '@/services/shared/role-policy'; import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access'; import { ROLE_NAMES } from '@/shared/constants/roles'; -import type { AuthenticatedUser, CurrentUser } from '@/db/api/types'; +import type { AuthenticatedUser, CurrentUser, FileInput } from '@/db/api/types'; /** * A non-global actor may only manage users in its own organization. Cross-tenant @@ -45,6 +51,58 @@ function parseCsvRows(fileBuffer: Buffer): Promise { }); } +async function normalizeTenantAssignment( + data: CreateData | UpdateData, + transaction: Transaction, +): Promise { + if (data.classId) { + const cls = await db.classes.findByPk(data.classId, { + attributes: ['id', 'campusId', 'organizationId'], + transaction, + }); + if (!cls) throw new ValidationError('classesNotFound'); + data.campusId = cls.campusId; + data.organizations = cls.organizationId ?? data.organizations; + } + + if (data.campusId) { + const campus = await db.campuses.findByPk(data.campusId, { + attributes: ['id', 'organizationId', 'schoolId'], + transaction, + }); + if (!campus) throw new ValidationError('campusesNotFound'); + data.schoolId = campus.schoolId; + data.organizations = campus.organizationId ?? data.organizations; + } + + if (data.schoolId) { + const school = await db.schools.findByPk(data.schoolId, { + attributes: ['id', 'organizationId'], + transaction, + }); + if (!school) throw new ValidationError('schoolsNotFound'); + data.organizations = school.organizationId ?? data.organizations; + } +} + +function normalizeAvatarInput(data: CreateData | UpdateData): void { + const rawAvatar = (data as { avatar?: unknown }).avatar; + if (rawAvatar === null) { + data.avatar = []; + return; + } + if (typeof rawAvatar !== 'string') { + return; + } + if (rawAvatar.length === 0) { + data.avatar = []; + return; + } + const privateUrl = rawAvatar; + const name = privateUrl.split('/').pop() || 'avatar'; + data.avatar = [{ new: true, name, privateUrl, publicUrl: privateUrl }] satisfies FileInput[]; +} + class UsersService { static async create( data: CreateData, @@ -57,6 +115,8 @@ class UsersService { const email = data.email; const emailsToInvite: string[] = []; + let createdId: string; + let createdOrganizationId: string | null = null; try { if (email) { @@ -70,7 +130,7 @@ class UsersService { const newRole = await db.roles.findByPk(data.app_role, { transaction, }); - assertCanManageUserWithRole(currentUser, newRole?.name ?? null); + assertCanCreateUserWithRole(currentUser, newRole?.name ?? null); // §3.4 provisioning: creating an `owner` auto-creates the company and // links the owner to it. The org starts minimal; the owner fills it in. @@ -80,6 +140,7 @@ class UsersService { { currentUser, transaction }, ); data.organizations = organization.id; + createdOrganizationId = organization.id; } } @@ -89,10 +150,14 @@ class UsersService { if (actorOrg) data.organizations = actorOrg; } - await UsersDBApi.create({ data }, globalAccess, { + await normalizeTenantAssignment(data, transaction); + normalizeAvatarInput(data); + + const created = await UsersDBApi.create({ data }, globalAccess, { currentUser, transaction, }); + createdId = created.id; emailsToInvite.push(email); } else { throw new ValidationError('iam.errors.emailRequired'); @@ -103,11 +168,36 @@ class UsersService { throw error; } - if (emailsToInvite.length) { - if (!sendInvitationEmails) return; - + if (emailsToInvite.length && sendInvitationEmails) { AuthService.sendPasswordResetEmail(emailsToInvite[0], 'invitation', host); } + + return { id: createdId, organizationId: createdOrganizationId }; + } + + static async createOwnerWithOrganization( + data: Omit, + currentUser?: CurrentUser, + sendInvitationEmails = true, + host?: string, + ) { + const ownerRole = await db.roles.findOne({ + where: { name: ROLE_NAMES.OWNER }, + }); + if (!ownerRole?.id) { + throw new ValidationError('iam.errors.roleNotFound'); + } + + return UsersService.create( + { + ...data, + app_role: ownerRole.id, + organizations: null, + }, + currentUser, + sendInvitationEmails, + host, + ); } static async bulkImport( @@ -127,6 +217,12 @@ class UsersService { throw new ValidationError('importer.errors.userEmailMissing'); } + for (const row of rows) { + if (!row.app_role) continue; + const role = await db.roles.findByPk(row.app_role, { transaction }); + assertCanCreateUserWithRole(currentUser, role?.name ?? null); + } + await UsersDBApi.bulkImport(rows, { transaction, ignoreDuplicates: true, @@ -166,11 +262,17 @@ class UsersService { // the actor must be allowed to manage the target's current role (and the // new role if it is being reassigned). assertSameTenant(currentUser, users); - assertCanManageUserWithRole(currentUser, users.app_role?.name ?? null); + assertCanUpdateUserWithRole(currentUser, users.app_role?.name ?? null); if (data.app_role) { const newRole = await db.roles.findByPk(data.app_role, { transaction }); - assertCanManageUserWithRole(currentUser, newRole?.name ?? null); + assertCanAssignUserRole( + currentUser, + users.app_role?.name ?? null, + newRole?.name ?? null, + ); } + await normalizeTenantAssignment(data, transaction); + normalizeAvatarInput(data); const updatedUser = await UsersDBApi.update(id, data, globalAccess, { currentUser, @@ -202,7 +304,7 @@ class UsersService { // the actor must be allowed to delete the target's role (e.g. a // superintendent cannot delete an owner or another superintendent). assertSameTenant(currentUser, target); - assertCanManageUserWithRole(currentUser, target.app_role?.name ?? null); + assertCanDeleteUserWithRole(currentUser, target.app_role?.name ?? null); await UsersDBApi.remove(id, { currentUser, transaction }); @@ -224,7 +326,7 @@ class UsersService { const target = await UsersDBApi.findBy({ id }, { transaction }); if (target) { assertSameTenant(currentUser, target); - assertCanManageUserWithRole( + assertCanDeleteUserWithRole( currentUser, target.app_role?.name ?? null, ); diff --git a/backend/src/services/walkthrough_checkins.ts b/backend/src/services/walkthrough_checkins.ts index 48a0267..5c26837 100644 --- a/backend/src/services/walkthrough_checkins.ts +++ b/backend/src/services/walkthrough_checkins.ts @@ -4,14 +4,16 @@ import { withTransaction } from '@/db/with-transaction'; import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; -import { WALKTHROUGH_MANAGER_ROLE_NAMES } from '@/shared/constants/walkthrough'; import { - campusDimensionScope, + getOwnTenant, + tenantExactWhere, + tenantStamp, getOrganizationIdOrGlobal, hasGlobalAccess, - hasRoleAccess, + hasFeaturePermission, requireUserId, } from '@/services/shared/access'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import type { WalkthroughCheckins } from '@/db/models/walkthrough_checkins'; import type { CurrentUser } from '@/db/api/types'; import type { @@ -37,17 +39,6 @@ const RATING_FIELDS = [ 'lesson_plan_rating', ] as const; -/** Walkthrough scopes by the staff member's campus only (never the user's own - * campusId), so this keeps its module-local resolver. */ -function getCampusId(currentUser?: CurrentUser): string | null { - const staff = currentUser?.staff_user; - if (Array.isArray(staff) && staff[0]?.campusId) { - return staff[0].campusId; - } - - return null; -} - function assertCanManage(currentUser?: CurrentUser): void { if (!currentUser?.id) { throw new ForbiddenError(); @@ -58,7 +49,13 @@ function assertCanManage(currentUser?: CurrentUser): void { } const organizationId = getOrganizationIdOrGlobal(currentUser); - if (organizationId && hasRoleAccess(currentUser, WALKTHROUGH_MANAGER_ROLE_NAMES)) { + if ( + organizationId + && hasFeaturePermission( + currentUser, + FEATURE_PERMISSIONS.MANAGE_WALKTHROUGH, + ) + ) { return; } @@ -111,6 +108,8 @@ function toDto(record: WalkthroughCheckins) { overall_notes: plain.overall_notes, organizationId: plain.organizationId, campusId: plain.campusId, + schoolId: plain.schoolId, + classId: plain.classId, createdById: plain.createdById, updatedById: plain.updatedById, createdAt: plain.createdAt, @@ -123,13 +122,11 @@ class WalkthroughCheckinsService { assertCanManage(currentUser); const { limit, offset } = resolvePagination(filter.limit, filter.page); - const organizationId = getOrganizationIdOrGlobal(currentUser); - const orgFilter = organizationId ? { organizationId } : {}; - + // Exact-tenant: a boss sees only their own tenant's walkthroughs (no + // cross-campus/org visibility); higher tiers reach a child's via drill. const result = await db.walkthrough_checkins.findAndCountAll({ where: { - ...orgFilter, - ...campusDimensionScope(currentUser), + ...tenantExactWhere(getOwnTenant(currentUser)), ...(filter.teacher_name ? { teacher_name: filter.teacher_name } : {}), }, order: [ @@ -150,7 +147,8 @@ class WalkthroughCheckinsService { assertCanManage(currentUser); assertValidCheckin(data); - const organizationId = getOrganizationIdOrGlobal(currentUser); + // A new walkthrough is owned by the evaluating boss's own tenant level. + const stamp = tenantStamp(getOwnTenant(currentUser)); return withTransaction(async (transaction) => { const created = await db.walkthrough_checkins.create( @@ -177,8 +175,10 @@ class WalkthroughCheckinsService { lesson_plan_rating: data.lesson_plan_rating, lesson_plan_comment: nullableString(data.lesson_plan_comment), overall_notes: nullableString(data.overall_notes), - organizationId: organizationId ?? undefined, - campusId: getCampusId(currentUser) ?? undefined, + organizationId: stamp.organizationId ?? undefined, + schoolId: stamp.schoolId, + campusId: stamp.campusId, + classId: stamp.classId, createdById: requireUserId(currentUser), updatedById: currentUser?.id ?? null, }, @@ -192,14 +192,10 @@ class WalkthroughCheckinsService { static async remove(id: string, currentUser?: CurrentUser) { assertCanManage(currentUser); - const organizationId = getOrganizationIdOrGlobal(currentUser); - const orgFilter = organizationId ? { organizationId } : {}; - const deletedCount = await db.walkthrough_checkins.destroy({ where: { id, - ...orgFilter, - ...campusDimensionScope(currentUser), + ...tenantExactWhere(getOwnTenant(currentUser)), }, }); diff --git a/backend/src/services/zone-checkin.test.ts b/backend/src/services/zone-checkin.test.ts new file mode 100644 index 0000000..4eac7bb --- /dev/null +++ b/backend/src/services/zone-checkin.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, mock, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import db from '@/db/models'; +import ZoneCheckinService from '@/services/zone-checkin'; +import UserProgressService from '@/services/user_progress'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { createGlobalAccessUser, createTestUser } from '@/test-utils'; +import type { CurrentUser } from '@/db/api/types'; + +function user(roleName: string): CurrentUser { + return createTestUser({ + campusId: 'campus-1', + app_role: { + name: roleName, + globalAccess: false, + permissions: [{ name: FEATURE_PERMISSIONS.ZONE_CHECKIN }], + }, + }); +} + +afterEach(() => { + mock.restoreAll(); +}); + +describe('ZoneCheckinService role eligibility', () => { + test('rejects global access without explicit ZONE_CHECKIN', async () => { + const rejectedUsers = [ + createGlobalAccessUser(), + ]; + + for (const currentUser of rejectedUsers) { + await assert.rejects( + () => ZoneCheckinService.today(currentUser), + ForbiddenError, + ); + } + }); + + test('allows an explicit ZONE_CHECKIN permission regardless of role name', async () => { + mock.method(db.campuses, 'findByPk', (async () => ({ + timezone: 'America/Phoenix', + })) as typeof db.campuses.findByPk); + mock.method(UserProgressService, 'list', (async () => ({ + rows: [], + count: 0, + })) as typeof UserProgressService.list); + + const result = await ZoneCheckinService.today(user(ROLE_NAMES.OWNER)); + + assert.equal(result.zone, null); + assert.equal(result.isCheckedInToday, false); + }); + + test('allows a campus workflow role to read today status', async () => { + mock.method(db.campuses, 'findByPk', (async () => ({ + timezone: 'America/Phoenix', + })) as typeof db.campuses.findByPk); + mock.method(UserProgressService, 'list', (async () => ({ + rows: [], + count: 0, + })) as typeof UserProgressService.list); + + const result = await ZoneCheckinService.today(user(ROLE_NAMES.TEACHER)); + + assert.equal(result.zone, null); + assert.equal(result.isCheckedInToday, false); + assert.match(result.date, /^\d{4}-\d{2}-\d{2}$/); + }); +}); diff --git a/backend/src/services/zone-checkin.ts b/backend/src/services/zone-checkin.ts index e8e9d34..df31149 100644 --- a/backend/src/services/zone-checkin.ts +++ b/backend/src/services/zone-checkin.ts @@ -1,7 +1,14 @@ import db from '@/db/models'; import ValidationError from '@/shared/errors/validation'; import UserProgressService from '@/services/user_progress'; -import { assertAuthenticatedTenantUser, getCampusId } from '@/services/shared/access'; +import { + assertAuthenticatedTenantUser, + getCampusId, + hasFeaturePermission, + isActingInOwnScope, +} from '@/services/shared/access'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { localDateInTimezone } from '@/shared/constants/timezone'; import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress'; import { @@ -30,6 +37,12 @@ export interface ZoneCheckinHistoryEntry { readonly zone: string; } +function assertCanZoneCheckIn(currentUser?: CurrentUser): void { + if (!hasFeaturePermission(currentUser, FEATURE_PERMISSIONS.ZONE_CHECKIN)) { + throw new ForbiddenError(); + } +} + async function resolveCampusTimezone(currentUser?: CurrentUser): Promise { const campusId = getCampusId(currentUser); if (!campusId) { @@ -50,6 +63,7 @@ class ZoneCheckinService { /** Today's check-in for the caller (campus-local date). */ static async today(currentUser?: CurrentUser): Promise { assertAuthenticatedTenantUser(currentUser); + assertCanZoneCheckIn(currentUser); const timezone = await resolveCampusTimezone(currentUser); const date = localDateInTimezone(timezone); @@ -67,12 +81,22 @@ class ZoneCheckinService { currentUser?: CurrentUser, ): Promise { assertAuthenticatedTenantUser(currentUser); + assertCanZoneCheckIn(currentUser); if (!isZoneCheckinColor(data.zone)) { throw new ValidationError('zoneCheckinInvalidZone'); } const timezone = await resolveCampusTimezone(currentUser); const date = localDateInTimezone(timezone); + if (!isActingInOwnScope(currentUser)) { + const { rows } = await UserProgressService.list( + { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, item_id: date }, + currentUser, + ); + const zone = rows[0]?.value ?? null; + return { date, zone, isCheckedInToday: zone !== null }; + } + await UserProgressService.upsert( { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, @@ -87,6 +111,7 @@ class ZoneCheckinService { /** Clears the caller's check-in for today (campus-local date). */ static async clearToday(currentUser?: CurrentUser): Promise { assertAuthenticatedTenantUser(currentUser); + assertCanZoneCheckIn(currentUser); const timezone = await resolveCampusTimezone(currentUser); const date = localDateInTimezone(timezone); @@ -103,6 +128,7 @@ class ZoneCheckinService { currentUser?: CurrentUser, ): Promise<{ rows: ZoneCheckinHistoryEntry[]; count: number }> { assertAuthenticatedTenantUser(currentUser); + assertCanZoneCheckIn(currentUser); const { rows } = await UserProgressService.list( { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, diff --git a/backend/src/shared/architecture/import-boundaries.test.ts b/backend/src/shared/architecture/import-boundaries.test.ts index fe87a50..6ca71fb 100644 --- a/backend/src/shared/architecture/import-boundaries.test.ts +++ b/backend/src/shared/architecture/import-boundaries.test.ts @@ -1,104 +1,274 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; +import { dirname, join, relative, sep } from 'node:path'; +import { dirname as projectDirname, normalize as normalizeProjectPath } from 'node:path/posix'; +import { fileURLToPath } from 'node:url'; -/** - * Enforces the backend layer import direction (see - * `backend/docs/backend-architecture-refactor-plan.md`): - * - * Route -> Controller -> Service (BLL) -> Repository/Model (DAL) -> DB - * shared/constants/types/errors are cross-cutting and depend on no layer. - * - * Hard invariants assert zero violations. Debt ceilings cap known violations so - * they cannot grow and are ratcheted down to 0 as the refactor phases land. - */ +type Layer = 'api' | 'business' | 'data' | 'shared' | 'other'; -const SRC = path.resolve(import.meta.dirname, '../..'); +interface SourceFile { + readonly absolutePath: string; + readonly projectPath: string; + readonly layer: Layer; + readonly source: string; +} -function listTs(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === 'node_modules') continue; - out.push(...listTs(full)); - } else if ( - entry.name.endsWith('.ts') && - !entry.name.endsWith('.test.ts') && - !entry.name.endsWith('.d.ts') - ) { - out.push(full); - } +interface ImportReference { + readonly specifier: string; + readonly projectPath: string | null; +} + +const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const sourceFileExtensions = ['.ts']; +const importPattern = + /(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\sfrom\s+)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g; + +const allowedApiToDataImports = [ + 'auth/auth.ts -> @/db/api/users', + 'middlewares/check-permissions.ts -> @/db/api/roles', +]; + +const allowedBusinessHttpImports = [ + 'services/auth.ts -> express', + 'services/file.ts -> @/middlewares/upload', + 'services/file.ts -> express', +]; + +const allowedDataToBusinessImports = [ + 'db/api/file.ts -> @/services/file', +]; + +function toProjectPath(absolutePath: string): string { + return relative(sourceRoot, absolutePath).split(sep).join('/'); +} + +function isSourceFile(fileName: string): boolean { + return sourceFileExtensions.some((extension) => fileName.endsWith(extension)); +} + +function isTestFile(projectPath: string): boolean { + return projectPath.endsWith('.test.ts') || projectPath.endsWith('.d.ts'); +} + +function getLayer(projectPath: string): Layer { + if ( + projectPath === 'index.ts' + || projectPath.startsWith('commands/') + || projectPath.startsWith('api/') + || projectPath.startsWith('auth/') + || projectPath.startsWith('middlewares/') + || projectPath.startsWith('routes/') + ) { + return 'api'; } - return out; + + if (projectPath.startsWith('services/')) { + return 'business'; + } + + if (projectPath.startsWith('db/')) { + return 'data'; + } + + if (projectPath.startsWith('shared/') || projectPath.startsWith('types/')) { + return 'shared'; + } + + return 'other'; } -const FILES = listTs(SRC).map((file) => ({ - rel: path.relative(SRC, file).split(path.sep).join('/'), - imports: [...readFileSync(file, 'utf8').matchAll(/from '([^']+)'/g)].map( - (m) => m[1], - ), -})); +function listSourceFiles(directory: string): readonly SourceFile[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = join(directory, entry.name); -const API_LAYER = ['@/api/', '@/routes/', '@/middlewares/']; + if (entry.isDirectory()) { + return listSourceFiles(absolutePath); + } -function violators(layerPrefixes: string[], banned: string[]): string[] { - return FILES.filter( - (f) => - layerPrefixes.some((p) => f.rel.startsWith(p)) && - f.imports.some((i) => banned.some((b) => i === b || i.startsWith(b))), - ).map((f) => f.rel); + if (!entry.isFile() || !isSourceFile(entry.name)) { + return []; + } + + const projectPath = toProjectPath(absolutePath); + + if (isTestFile(projectPath) || projectPath.startsWith('test-utils/')) { + return []; + } + + return [ + { + absolutePath, + projectPath, + layer: getLayer(projectPath), + source: readFileSync(absolutePath, 'utf8'), + }, + ]; + }); } -// ---- Hard invariants (must be 0) ---- +function resolveProjectImport(importerProjectPath: string, specifier: string): string | null { + if (specifier.startsWith('@/')) { + return specifier.slice(2); + } -test('DAL models import no BLL, API layer, or Express', () => { + if (!specifier.startsWith('.')) { + return null; + } + + return normalizeProjectPath(`${projectDirname(importerProjectPath)}/${specifier}`); +} + +function readImports(sourceFile: SourceFile): readonly ImportReference[] { + const imports: ImportReference[] = []; + const matches = sourceFile.source.matchAll(importPattern); + + for (const match of matches) { + const specifier = match[1] ?? match[2]; + + if (specifier === undefined) { + continue; + } + + imports.push({ + specifier, + projectPath: resolveProjectImport(sourceFile.projectPath, specifier), + }); + } + + return imports; +} + +function formatViolation(sourceFile: SourceFile, importReference: ImportReference): string { + return `${sourceFile.projectPath} -> ${importReference.specifier}`; +} + +function collectViolations( + sourceFiles: readonly SourceFile[], + isSourceInScope: (sourceFile: SourceFile) => boolean, + isForbiddenImport: (sourceFile: SourceFile, importReference: ImportReference) => boolean, +): readonly string[] { + return sourceFiles + .filter(isSourceInScope) + .flatMap((sourceFile) => + readImports(sourceFile) + .filter((importReference) => isForbiddenImport(sourceFile, importReference)) + .map((importReference) => formatViolation(sourceFile, importReference)), + ) + .sort(); +} + +function withoutAllowed(violations: readonly string[], allowed: readonly string[]): readonly string[] { + const allowedSet = new Set(allowed); + return violations.filter((violation) => !allowedSet.has(violation)); +} + +function assertOnlyAllowed(violations: readonly string[], allowed: readonly string[]): void { + assert.deepEqual(withoutAllowed(violations, allowed), []); assert.deepEqual( - violators(['db/models/'], ['@/services/', 'express', ...API_LAYER]), - [], + violations.filter((violation) => allowed.includes(violation)).sort(), + [...allowed].sort(), ); -}); +} -test('cross-cutting code (shared/*) imports no layer', () => { - assert.deepEqual( - violators( - ['shared/'], - ['@/api/', '@/routes/', '@/middlewares/', '@/services/', '@/db/'], - ), - [], +function isProjectLayer(importReference: ImportReference, layer: Layer): boolean { + return importReference.projectPath !== null && getLayer(importReference.projectPath) === layer; +} + +function isHttpImport(importReference: ImportReference): boolean { + return importReference.specifier === 'express' + || importReference.specifier.startsWith('@/api/') + || importReference.specifier.startsWith('@/auth/') + || importReference.specifier.startsWith('@/middlewares/') + || importReference.specifier.startsWith('@/routes/'); +} + +test('API layer does not import data directly beyond explicit edge wiring', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.layer === 'api', + (_sourceFile, importReference) => isProjectLayer(importReference, 'data'), ); + + assertOnlyAllowed(violations, allowedApiToDataImports); }); -test('DAL does not import the API layer', () => { - assert.deepEqual(violators(['db/'], API_LAYER), []); +test('all production source files are assigned to an architecture layer', () => { + const unclassifiedFiles = listSourceFiles(sourceRoot) + .filter((sourceFile) => sourceFile.layer === 'other') + .map((sourceFile) => sourceFile.projectPath) + .sort(); + + assert.deepEqual(unclassifiedFiles, []); }); -// ---- Hard invariant earned in Phase 1-2 ---- - -test('API layer (routes, controllers) does not import the DAL', () => { - assert.deepEqual( - violators(['routes/', 'api/controllers/'], ['@/db/api', '@/db/models']), - [], +test('routes stay thin: no direct business or data imports', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.projectPath.startsWith('routes/'), + (_sourceFile, importReference) => + isProjectLayer(importReference, 'business') || isProjectLayer(importReference, 'data'), ); + + assert.deepEqual(violations, []); }); -// ---- Debt ceilings (must not grow; lowered to 0 across phases) ---- - -test('BLL depends on HTTP only in the streaming/session edge cases', () => { - // Remaining: `services/file.ts` (upload/download streaming) and - // `services/auth.ts` (session IP/UA + cookies). To be revisited. - const v = violators(['services/'], ['express', ...API_LAYER]); - assert.ok( - v.length <= 2, - `services depending on HTTP grew to ${v.length} (>2: only file/auth remain)`, +test('controllers do not import data repositories or models directly', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.projectPath.startsWith('api/controllers/'), + (_sourceFile, importReference) => isProjectLayer(importReference, 'data'), ); + + assert.deepEqual(violations, []); }); -test('DAL depending on BLL — ceiling (→ 0)', () => { - const v = violators(['db/api/'], ['@/services/']); - assert.ok( - v.length <= 1, - `repositories importing services grew to ${v.length} (>1)`, +test('business layer keeps HTTP dependencies limited to known edge cases', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.layer === 'business', + (_sourceFile, importReference) => isHttpImport(importReference), ); + + assertOnlyAllowed(violations, allowedBusinessHttpImports); +}); + +test('data layer does not import API, business, or Express beyond explicit file storage bridge', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.layer === 'data', + (_sourceFile, importReference) => + isProjectLayer(importReference, 'api') + || isProjectLayer(importReference, 'business') + || importReference.specifier === 'express', + ); + + assertOnlyAllowed(violations, allowedDataToBusinessImports); +}); + +test('model layer imports no business, API, or Express modules', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.projectPath.startsWith('db/models/'), + (_sourceFile, importReference) => + isProjectLayer(importReference, 'api') + || isProjectLayer(importReference, 'business') + || importReference.specifier === 'express', + ); + + assert.deepEqual(violations, []); +}); + +test('shared code stays cross-cutting and imports no application layer', () => { + const violations = collectViolations( + listSourceFiles(sourceRoot), + (sourceFile) => sourceFile.layer === 'shared', + (_sourceFile, importReference) => + isProjectLayer(importReference, 'api') + || isProjectLayer(importReference, 'business') + || isProjectLayer(importReference, 'data') + || importReference.specifier === 'express', + ); + + assert.deepEqual(violations, []); }); diff --git a/backend/src/shared/constants/campus-attendance.ts b/backend/src/shared/constants/campus-attendance.ts index a14bf61..d4735cc 100644 --- a/backend/src/shared/constants/campus-attendance.ts +++ b/backend/src/shared/constants/campus-attendance.ts @@ -1,15 +1,3 @@ -import { ROLE_NAMES } from './roles'; - -export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.DIRECTOR, - ROLE_NAMES.OFFICE_MANAGER, -]); - export const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120; export const CAMPUS_ATTENDANCE_MAX_LIMIT = 366; diff --git a/backend/src/shared/constants/communications.ts b/backend/src/shared/constants/communications.ts index 89454f9..1c0f7c4 100644 --- a/backend/src/shared/constants/communications.ts +++ b/backend/src/shared/constants/communications.ts @@ -1,22 +1,3 @@ -import { ROLE_NAMES } from './roles'; - -export const COMMUNICATION_CHANNELS = Object.freeze({ - IN_APP: 'in_app', -}); - -export const COMMUNICATION_AUDIENCES = Object.freeze({ - GUARDIANS: 'guardians', - STAFF: 'staff', -}); - -export const COMMUNICATION_STATUSES = Object.freeze({ - SENT: 'sent', -}); - -export const COMMUNICATION_RECIPIENT_TYPES = Object.freeze({ - GUARDIAN: 'guardian', -}); - export const COMMUNICATION_EVENT_TYPES = Object.freeze({ MEETING: 'meeting', DRILL: 'drill', @@ -28,27 +9,3 @@ export type CommunicationEventType = 'meeting' | 'drill' | 'event' | 'deadline'; export const COMMUNICATION_EVENT_TYPE_VALUES: readonly CommunicationEventType[] = ['meeting', 'drill', 'event', 'deadline']; - -export type ParentMessageCategory = - | 'behavior' - | 'event' - | 'progress' - | 'general'; - -export const PARENT_MESSAGE_CATEGORY_VALUES: readonly ParentMessageCategory[] = [ - 'behavior', - 'event', - 'progress', - 'general', -]; - -export const DEFAULT_PARENT_MESSAGE_CATEGORY: ParentMessageCategory = 'general'; - -export const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.DIRECTOR, -]); diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index a0d9ff9..1627fe2 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -1,10 +1,52 @@ -import { ROLE_NAMES } from './roles'; +/** Classroom Support — org-scoped but also editable by a campus director. */ +export const CLASSROOM_SUPPORT_CONTENT_TYPE = 'classroom-strategies'; -export const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.DIRECTOR, +/** The safety/QBS quiz content type, dedicated per tenant (org/school/campus). */ +export const SAFETY_QUIZ_CONTENT_TYPE = 'safety-qbs-quiz'; + +/** ESA funding content — school-scoped (rules depend on the school's locale). */ +export const ESA_CONTENT_TYPE = 'esa-funding-content'; + +/** + * **Per-tenant** content types (read/written at the user's own tenant level via + * `getOwnTenant`; preset at organization + school + campus levels). The safety + * quiz, dashboard, and parent templates span org/school/campus. + */ +export const PER_TENANT_CONTENT_TYPES: ReadonlySet = new Set([ + SAFETY_QUIZ_CONTENT_TYPE, + 'dashboard-sign-of-week', + 'dashboard-teacher-images', + 'dashboard-encouraging-quotes', + 'dashboard-compliance-items', +]); + +/** **School-scoped** content types (one per school). */ +export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ + ESA_CONTENT_TYPE, +]); + +/** **Org-scoped** content types (one per organization; preset at org creation). */ +export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ + CLASSROOM_SUPPORT_CONTENT_TYPE, + 'regulation-zones', + 'zones-of-regulation-page-content', + 'sign-language-items', + 'sign-language-page-content', + 'emotional-intelligence-assessment-questions', + 'emotional-intelligence-weekly-topics', + 'emotional-intelligence-growth-tips', + 'emotional-intelligence-team-wellness-metrics', + 'emotional-intelligence-weekly-focus', + 'community-organizations', + 'vocational-opportunities', +]); + +/** + * All tenant-scoped content types (per-tenant ∪ school ∪ org). Truly global + * personality and classroom-timer catalogs live in frontend static constants. + */ +export const TENANT_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ + ...PER_TENANT_CONTENT_TYPES, + ...SCHOOL_SCOPED_CONTENT_TYPES, + ...ORG_SCOPED_CONTENT_TYPES, ]); diff --git a/backend/src/shared/constants/frame.ts b/backend/src/shared/constants/frame.ts deleted file mode 100644 index e52695d..0000000 --- a/backend/src/shared/constants/frame.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ROLE_NAMES } from './roles'; - -export const FRAME_EDITOR_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.DIRECTOR, -]); diff --git a/backend/src/shared/constants/personality.ts b/backend/src/shared/constants/personality.ts deleted file mode 100644 index 7c72119..0000000 --- a/backend/src/shared/constants/personality.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ROLE_NAMES } from './roles'; - -export const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.REGISTRAR, - ROLE_NAMES.DIRECTOR, -]); diff --git a/backend/src/shared/constants/product-permissions.ts b/backend/src/shared/constants/product-permissions.ts index b110389..164d9e6 100644 --- a/backend/src/shared/constants/product-permissions.ts +++ b/backend/src/shared/constants/product-permissions.ts @@ -20,6 +20,9 @@ export const MODULE_READ_ALL_STAFF = [ 'READ_HANDBOOK', ] as const; +/** Platform dashboard page. Global users pass this through global access. */ +export const MODULE_READ_PLATFORM = ['READ_PLATFORM_DASHBOARD'] as const; + /** Instructional tools (teacher / support_staff, not office_manager). */ export const MODULE_READ_INSTRUCTIONAL = [ 'READ_CLASSROOM', @@ -29,7 +32,7 @@ export const MODULE_READ_INSTRUCTIONAL = [ 'READ_SIGNS', ] as const; -/** Parent communication page (teacher + managers). */ +/** Parent communication page (teacher + office manager + guardians). */ export const MODULE_READ_PARENT_COMM = ['READ_PARENT_COMM'] as const; /** External-user pages (student / guardian + staff). */ @@ -54,17 +57,31 @@ export const MODULE_ACTIONS = [ 'ZONE_CHECKIN', ] as const; +/** Feature-level management/report permissions delegated through User Admin. */ +export const MODULE_MANAGEMENT_PERMISSIONS = [ + 'MANAGE_FRAME', + 'MANAGE_WALKTHROUGH', + 'MANAGE_INTERNAL_COMM', + 'MANAGE_CONTENT_CATALOG', + 'READ_STAFF_ATTENDANCE_REPORTS', + 'READ_SAFETY_QUIZ_REPORTS', + 'READ_PERSONALITY_REPORTS', + 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', +] as const; + /** Audio library (Workstream 13): read = play/select, manage = upload/edit. */ export const AUDIO_PERMISSIONS = ['READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES'] as const; /** Every product-feature permission (seeded into the catalog). */ export const MODULE_PERMISSIONS: readonly string[] = Object.freeze([ + ...MODULE_READ_PLATFORM, ...MODULE_READ_ALL_STAFF, ...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_PARENT_COMM, ...MODULE_READ_EXTERNAL, ...MODULE_READ_DIRECTOR, ...MODULE_ACTIONS, + ...MODULE_MANAGEMENT_PERMISSIONS, ...AUDIO_PERMISSIONS, ]); @@ -74,6 +91,7 @@ export const MODULE_PERMISSIONS: readonly string[] = Object.freeze([ */ export const FEATURE_PERMISSIONS = Object.freeze({ READ_FRAME: 'READ_FRAME', + READ_PLATFORM_DASHBOARD: 'READ_PLATFORM_DASHBOARD', READ_ATTENDANCE: 'READ_ATTENDANCE', READ_INTERNAL_COMM: 'READ_INTERNAL_COMM', READ_PARENT_COMM: 'READ_PARENT_COMM', @@ -84,6 +102,24 @@ export const FEATURE_PERMISSIONS = Object.freeze({ ACK_READ_RECEIPT: 'ACK_READ_RECEIPT', ACK_POLICY: 'ACK_POLICY', ZONE_CHECKIN: 'ZONE_CHECKIN', + MANAGE_FRAME: 'MANAGE_FRAME', + MANAGE_WALKTHROUGH: 'MANAGE_WALKTHROUGH', + MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM', + MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG', + READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS', + READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS', + READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS', + READ_POLICY_ACKNOWLEDGMENT_REPORTS: 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', READ_AUDIO_FILES: 'READ_AUDIO_FILES', MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', }); + +/** + * Personal workflow permissions must be explicitly granted. Platform + * `globalAccess` does not imply these self-service actions. + */ +export const GLOBAL_BYPASS_EXCLUDED_PERMISSIONS = [ + FEATURE_PERMISSIONS.READ_PARENT_COMM, + FEATURE_PERMISSIONS.ACK_POLICY, + FEATURE_PERMISSIONS.ZONE_CHECKIN, +] as const; diff --git a/backend/src/shared/constants/roles.ts b/backend/src/shared/constants/roles.ts index d5a0adc..5ea7d03 100644 --- a/backend/src/shared/constants/roles.ts +++ b/backend/src/shared/constants/roles.ts @@ -3,13 +3,14 @@ * a role's reach: platform-wide, a single organization, a single school (all its * campuses), a single campus, the external-user surface, or the unauthenticated * guest. Stored on `roles.scope`. Hierarchy: SYSTEM ⊃ ORGANIZATION ⊃ SCHOOL ⊃ - * CAMPUS. + * CAMPUS ⊃ CLASS (a classroom led by a teacher). */ export const ROLE_SCOPES = Object.freeze({ SYSTEM: 'system', ORGANIZATION: 'organization', SCHOOL: 'school', CAMPUS: 'campus', + CLASS: 'class', EXTERNAL: 'external', GUEST: 'guest', }); @@ -63,8 +64,8 @@ export const ROLE_DEFINITIONS: readonly RoleDefinition[] = Object.freeze([ { name: ROLE_NAMES.REGISTRAR, scope: ROLE_SCOPES.SCHOOL, globalAccess: false }, { name: ROLE_NAMES.DIRECTOR, scope: ROLE_SCOPES.CAMPUS, globalAccess: false }, { name: ROLE_NAMES.OFFICE_MANAGER, scope: ROLE_SCOPES.CAMPUS, globalAccess: false }, - { name: ROLE_NAMES.TEACHER, scope: ROLE_SCOPES.CAMPUS, globalAccess: false }, - { name: ROLE_NAMES.SUPPORT_STAFF, scope: ROLE_SCOPES.CAMPUS, globalAccess: false }, + { name: ROLE_NAMES.TEACHER, scope: ROLE_SCOPES.CLASS, globalAccess: false }, + { name: ROLE_NAMES.SUPPORT_STAFF, scope: ROLE_SCOPES.CLASS, globalAccess: false }, { name: ROLE_NAMES.STUDENT, scope: ROLE_SCOPES.EXTERNAL, globalAccess: false }, { name: ROLE_NAMES.GUARDIAN, scope: ROLE_SCOPES.EXTERNAL, globalAccess: false }, { name: ROLE_NAMES.GUEST, scope: ROLE_SCOPES.GUEST, globalAccess: false }, @@ -73,10 +74,3 @@ export const ROLE_DEFINITIONS: readonly RoleDefinition[] = Object.freeze([ export const ROLE_NAME_VALUES: readonly RoleName[] = Object.freeze( ROLE_DEFINITIONS.map((role) => role.name), ); - -/** Staff HR type → the campus role assigned to that staff member. */ -export const STAFF_TYPE_TO_ROLE_NAME: Record = Object.freeze({ - teacher: ROLE_NAMES.TEACHER, - admin: ROLE_NAMES.OFFICE_MANAGER, - support: ROLE_NAMES.SUPPORT_STAFF, -}); diff --git a/backend/src/shared/constants/safety-quiz.ts b/backend/src/shared/constants/safety-quiz.ts deleted file mode 100644 index fd98b49..0000000 --- a/backend/src/shared/constants/safety-quiz.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ROLE_NAMES } from './roles'; - -export const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.REGISTRAR, - ROLE_NAMES.DIRECTOR, -]); diff --git a/backend/src/shared/constants/seed-fixtures.test.ts b/backend/src/shared/constants/seed-fixtures.test.ts new file mode 100644 index 0000000..112c9a0 --- /dev/null +++ b/backend/src/shared/constants/seed-fixtures.test.ts @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { + SEED_ALL_USERS, + SEED_CAMPUS_ID, + SEED_CLASS_ID, + SEED_FIXTURE_USERS, + SEED_SECONDARY_CAMPUS_ID, + SEED_SECONDARY_CLASS_ID, + SEED_SECONDARY_SCHOOL_ID, + SEED_SECONDARY_USERS, + SEED_SCHOOL_ID, +} from '@/shared/constants/seed-fixtures'; + +const TENANT_ROLE_NAMES: readonly RoleName[] = [ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.PRINCIPAL, + ROLE_NAMES.REGISTRAR, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, + ROLE_NAMES.STUDENT, + ROLE_NAMES.GUARDIAN, +]; + +function rolesOf(users: readonly { readonly role: RoleName }[]): readonly RoleName[] { + return users.map((user) => user.role); +} + +test('primary seed tenant has one login user for every platform role', () => { + assert.deepEqual(rolesOf(SEED_FIXTURE_USERS), [ + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ...TENANT_ROLE_NAMES, + ]); +}); + +test('secondary seed tenant has every non-system tenant role', () => { + assert.deepEqual(rolesOf(SEED_SECONDARY_USERS), TENANT_ROLE_NAMES); +}); + +test('all seeded users have unique ids and emails', () => { + const ids = SEED_ALL_USERS.map((user) => user.id); + const emails = SEED_ALL_USERS.map((user) => user.email); + + assert.equal(new Set(ids).size, ids.length); + assert.equal(new Set(emails).size, emails.length); +}); + +test('secondary tenant topology is separate from primary school, campus, and class', () => { + assert.notEqual(SEED_SECONDARY_SCHOOL_ID, SEED_SCHOOL_ID); + assert.notEqual(SEED_SECONDARY_CAMPUS_ID, SEED_CAMPUS_ID); + assert.notEqual(SEED_SECONDARY_CLASS_ID, SEED_CLASS_ID); +}); diff --git a/backend/src/shared/constants/seed-fixtures.ts b/backend/src/shared/constants/seed-fixtures.ts index 1abcd93..061e466 100644 --- a/backend/src/shared/constants/seed-fixtures.ts +++ b/backend/src/shared/constants/seed-fixtures.ts @@ -7,10 +7,10 @@ import { /** * RBAC seed fixtures (Workstream 4): one company, two schools, the six product - * campuses, staff covering every campus role, and exactly one loginable user per + * campuses, campus-role users, and exactly one loginable user per * stored role. Single source shared by the admin-user seeder (creates the * users), the user-roles seeder (assigns roles), and the rbac-fixtures seeder - * (org/school/campus/staff links). Pre-launch — reset the DB and reseed. + * (org/school/campus links). Pre-launch — reset the DB and reseed. */ export const SEED_ORGANIZATION_ID = 'b1a7c0de-0000-4000-8000-000000000001'; @@ -27,9 +27,17 @@ export const SEED_SCHOOL_NAME = 'Demo Academy North'; export const SEED_SCHOOL_2_ID = 'b1a7c0de-0000-4000-8000-000000000032'; export const SEED_SCHOOL_2_NAME = 'Demo Academy South'; -/** The campus the fixture staff are assigned to (the seeded `tigers` campus). */ +/** The campus the fixture campus-role users are assigned to (the seeded `tigers` campus). */ export const SEED_CAMPUS_ID = PRODUCT_CAMPUS_SEED_ROWS[0].id; +/** + * A seeded class (the `tigers` campus homeroom) for the Class tier. The teacher/ + * support_staff fixtures are assigned to it, the student is enrolled, and the + * guardian is linked to that student. + */ +export const SEED_CLASS_ID = 'b1a7c0de-0000-4000-8000-000000000041'; +export const SEED_CLASS_NAME = 'Tigers Homeroom 1'; + /** Campus → school assignment (campus belongs to exactly one school). */ export const SEED_SCHOOL_CAMPUS_IDS: Readonly> = Object.freeze({ @@ -37,8 +45,6 @@ export const SEED_SCHOOL_CAMPUS_IDS: Readonly> [SEED_SCHOOL_2_ID]: PRODUCT_CAMPUS_SEED_ROWS.slice(3).map((c) => c.id), }); -export type StaffType = 'teacher' | 'admin' | 'support'; - export interface SeedFixtureUser { readonly id: string; readonly email: string; @@ -46,6 +52,7 @@ export interface SeedFixtureUser { readonly namePrefix?: UserNamePrefix; readonly firstName: string; readonly lastName: string; + readonly phoneNumber?: string; readonly role: RoleName; /** Uses `SEED_ADMIN_PASSWORD` (system roles) vs `SEED_USER_PASSWORD`. */ readonly admin: boolean; @@ -55,8 +62,6 @@ export interface SeedFixtureUser { readonly school: boolean; /** Gets `campusId` (campus + external roles). */ readonly campus: boolean; - /** When set, a staff profile is created with this `staff_type`. */ - readonly staffType?: StaffType; } export const SEED_FIXTURE_USERS: readonly SeedFixtureUser[] = [ @@ -66,38 +71,55 @@ export const SEED_FIXTURE_USERS: readonly SeedFixtureUser[] = [ { id: 'b1a7c0de-0000-4000-8000-000000000013', email: 'superintendent@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Michael', lastName: 'Torres', role: ROLE_NAMES.SUPERINTENDENT, admin: false, organization: true, school: false, campus: false }, { id: 'b1a7c0de-0000-4000-8000-000000000021', email: 'principal@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Karen', lastName: 'Mitchell', role: ROLE_NAMES.PRINCIPAL, admin: false, organization: true, school: true, campus: false }, { id: 'b1a7c0de-0000-4000-8000-000000000022', email: 'registrar@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Nicole', lastName: 'Adams', role: ROLE_NAMES.REGISTRAR, admin: false, organization: true, school: true, campus: false }, - { id: 'b1a7c0de-0000-4000-8000-000000000014', email: 'director@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Sarah', lastName: 'Williams', role: ROLE_NAMES.DIRECTOR, admin: false, organization: true, school: true, campus: true, staffType: 'admin' }, - { id: 'b1a7c0de-0000-4000-8000-000000000015', email: 'office_manager@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Lisa', lastName: 'Park', role: ROLE_NAMES.OFFICE_MANAGER, admin: false, organization: true, school: true, campus: true, staffType: 'admin' }, - { id: 'b1a7c0de-0000-4000-8000-000000000016', email: 'teacher@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Emily', lastName: 'Johnson', role: ROLE_NAMES.TEACHER, admin: false, organization: true, school: true, campus: true, staffType: 'teacher' }, - { id: 'b1a7c0de-0000-4000-8000-000000000017', email: 'support_staff@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Marcus', lastName: 'Davis', role: ROLE_NAMES.SUPPORT_STAFF, admin: false, organization: true, school: true, campus: true, staffType: 'support' }, + { id: 'b1a7c0de-0000-4000-8000-000000000014', email: 'director@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Sarah', lastName: 'Williams', role: ROLE_NAMES.DIRECTOR, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000015', email: 'office_manager@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Lisa', lastName: 'Park', role: ROLE_NAMES.OFFICE_MANAGER, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000016', email: 'teacher@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Emily', lastName: 'Johnson', role: ROLE_NAMES.TEACHER, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000017', email: 'support_staff@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Marcus', lastName: 'Davis', role: ROLE_NAMES.SUPPORT_STAFF, admin: false, organization: true, school: true, campus: true }, { id: 'b1a7c0de-0000-4000-8000-000000000018', email: 'student@flatlogic.com', firstName: 'Emma', lastName: 'Clark', role: ROLE_NAMES.STUDENT, admin: false, organization: true, school: true, campus: true }, { id: 'b1a7c0de-0000-4000-8000-000000000019', email: 'guardian@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Robert', lastName: 'Clark', role: ROLE_NAMES.GUARDIAN, admin: false, organization: true, school: true, campus: true }, ]; /** - * A **second tenant** used only to prove cross-tenant isolation in tests - * (Workstream 8). It is one organization with one `owner` whose data must never - * be visible or mutable to the primary-tenant users above. Kept separate from - * `SEED_FIXTURE_USERS` so the "exactly one user per role in one company" - * invariant of the primary fixtures still holds. + * A **second tenant** used to prove cross-tenant isolation and to support + * manual QA across tenant roles. It has the same non-system user topology as + * the primary tenant: owner, superintendent, school roles, campus staff, + * student, and guardian. System roles stay global and are not tenant-owned. */ export const SEED_ORGANIZATION_2_ID = 'b1a7c0de-0000-4000-8000-000000000002'; export const SEED_ORGANIZATION_2_NAME = 'Rival Academy'; -export const SEED_SECONDARY_OWNER: SeedFixtureUser = { - id: 'b1a7c0de-0000-4000-8000-000000000020', - email: 'owner2@flatlogic.com', - firstName: 'Mr. David', - lastName: 'Martinez', - role: ROLE_NAMES.OWNER, - admin: false, - organization: true, - school: false, - campus: false, -}; +export const SEED_SECONDARY_SCHOOL_ID = 'b1a7c0de-0000-4000-8000-000000000061'; +export const SEED_SECONDARY_SCHOOL_NAME = 'Rival Academy North'; +export const SEED_SECONDARY_CAMPUS_ID = 'b1a7c0de-0000-4000-8000-000000000062'; +export const SEED_SECONDARY_CLASS_ID = 'b1a7c0de-0000-4000-8000-000000000063'; +export const SEED_SECONDARY_CLASS_NAME = 'Rival Homeroom 1'; -/** Every seeded login user: the per-role primary fixtures + the 2nd-tenant owner. */ +export const SEED_SECONDARY_USERS: readonly SeedFixtureUser[] = [ + { + id: 'b1a7c0de-0000-4000-8000-000000000020', + email: 'owner2@flatlogic.com', + namePrefix: USER_NAME_PREFIXES.MR, + firstName: 'David', + lastName: 'Martinez', + role: ROLE_NAMES.OWNER, + admin: false, + organization: true, + school: false, + campus: false, + }, + { id: 'b1a7c0de-0000-4000-8000-000000000050', email: 'superintendent2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Alicia', lastName: 'Reed', role: ROLE_NAMES.SUPERINTENDENT, admin: false, organization: true, school: false, campus: false }, + { id: 'b1a7c0de-0000-4000-8000-000000000051', email: 'principal2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.DR, firstName: 'Henry', lastName: 'Cole', role: ROLE_NAMES.PRINCIPAL, admin: false, organization: true, school: true, campus: false }, + { id: 'b1a7c0de-0000-4000-8000-000000000052', email: 'registrar2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Maya', lastName: 'Singh', role: ROLE_NAMES.REGISTRAR, admin: false, organization: true, school: true, campus: false }, + { id: 'b1a7c0de-0000-4000-8000-000000000053', email: 'director2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Ethan', lastName: 'Brooks', role: ROLE_NAMES.DIRECTOR, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000054', email: 'office_manager2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Olivia', lastName: 'Grant', role: ROLE_NAMES.OFFICE_MANAGER, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000055', email: 'teacher2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Nora', lastName: 'Bennett', role: ROLE_NAMES.TEACHER, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000056', email: 'support_staff2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Caleb', lastName: 'Price', role: ROLE_NAMES.SUPPORT_STAFF, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000057', email: 'student2@flatlogic.com', firstName: 'Liam', lastName: 'Martinez', role: ROLE_NAMES.STUDENT, admin: false, organization: true, school: true, campus: true }, + { id: 'b1a7c0de-0000-4000-8000-000000000058', email: 'guardian2@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Sofia', lastName: 'Martinez', role: ROLE_NAMES.GUARDIAN, admin: false, organization: true, school: true, campus: true }, +]; + +/** Every seeded login user: primary fixtures + the full secondary tenant. */ export const SEED_ALL_USERS: readonly SeedFixtureUser[] = [ ...SEED_FIXTURE_USERS, - SEED_SECONDARY_OWNER, + ...SEED_SECONDARY_USERS, ]; diff --git a/backend/src/shared/constants/staff-attendance.ts b/backend/src/shared/constants/staff-attendance.ts index 38c2ab7..e42723e 100644 --- a/backend/src/shared/constants/staff-attendance.ts +++ b/backend/src/shared/constants/staff-attendance.ts @@ -1,20 +1,8 @@ -import { ROLE_NAMES } from './roles'; - export const STAFF_ATTENDANCE_STATUSES = Object.freeze({ PRESENT: 'present', LATE: 'late', ABSENT: 'absent', }); -export const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.REGISTRAR, - ROLE_NAMES.DIRECTOR, -]); - export const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90; export const STAFF_ATTENDANCE_MAX_LIMIT = 366; diff --git a/backend/src/shared/constants/staff.ts b/backend/src/shared/constants/staff.ts deleted file mode 100644 index e4849d0..0000000 --- a/backend/src/shared/constants/staff.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Lifecycle status values for a staff record (the `staff.status` column). */ -export const STAFF_STATUSES = Object.freeze({ - ACTIVE: 'active', -}); diff --git a/backend/src/shared/constants/user-progress.ts b/backend/src/shared/constants/user-progress.ts index b7fab74..1de4583 100644 --- a/backend/src/shared/constants/user-progress.ts +++ b/backend/src/shared/constants/user-progress.ts @@ -1,13 +1,18 @@ export const USER_PROGRESS_TYPES = Object.freeze({ SIGN_LEARNED: 'sign_learned', ZONE_CHECKIN: 'zone_checkin', + CLASSROOM_STRATEGY_FAVORITE: 'classroom_strategy_favorite', }); -export type UserProgressType = 'sign_learned' | 'zone_checkin'; +export type UserProgressType = + | 'sign_learned' + | 'zone_checkin' + | 'classroom_strategy_favorite'; export const USER_PROGRESS_TYPE_VALUES: readonly UserProgressType[] = [ 'sign_learned', 'zone_checkin', + 'classroom_strategy_favorite', ]; export const ZONE_CHECKIN_ITEM_ID = 'current'; diff --git a/backend/src/shared/constants/walkthrough.ts b/backend/src/shared/constants/walkthrough.ts deleted file mode 100644 index 3ad8c42..0000000 --- a/backend/src/shared/constants/walkthrough.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ROLE_NAMES } from './roles'; - -export const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ - ROLE_NAMES.SUPER_ADMIN, - ROLE_NAMES.SYSTEM_ADMIN, - ROLE_NAMES.OWNER, - ROLE_NAMES.SUPERINTENDENT, - ROLE_NAMES.PRINCIPAL, - ROLE_NAMES.DIRECTOR, -]); diff --git a/backend/src/shared/tenancy.ts b/backend/src/shared/tenancy.ts new file mode 100644 index 0000000..9495ddf --- /dev/null +++ b/backend/src/shared/tenancy.ts @@ -0,0 +1,115 @@ +import { ROLE_SCOPES } from '@/shared/constants/roles'; + +/** + * Per-tenant content helpers (FRAME / quizzes / documents). Each tenant level + * (org / school / campus / class) owns its own dedicated content — not an + * aggregate of children. Pure functions of the authenticated user, kept in + * `shared/` so both the data layer (`db/api/*`) and the business layer + * (`services/*`) can use them without crossing the layer boundary. + */ + +export type TenantLevel = 'organization' | 'school' | 'campus' | 'class'; + +/** The minimal shape these helpers read off the authenticated user. */ +export interface TenantUser { + organizations?: { id: string | null } | null; + organizationId?: string | null; + schoolId?: string | null; + campusId?: string | null; + classId?: string | null; + app_role?: { globalAccess?: boolean | null; scope?: string | null } | null; + activeScope?: { + level: 'organization' | 'school' | 'campus' | 'class'; + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; + } | null; +} + +export interface ActiveTenant { + organizationId: string | null; + level: TenantLevel; + schoolId: string | null; + campusId: string | null; + classId: string | null; +} + +function orgId(user?: TenantUser): string | null { + return user?.organizations?.id || user?.organizationId || null; +} + +function leaf(user: TenantUser | undefined, key: 'schoolId' | 'campusId' | 'classId'): string | null { + return user?.[key] || null; +} + +/** The current user's own tenant (their scope level + matching leaf id). */ +export function getOwnTenant(user?: TenantUser): ActiveTenant { + // Drill-down override: act as the drilled tenant. A class owns no content, so + // a class-level drill resolves to its campus for content purposes. + const a = user?.activeScope; + if (a) { + if (a.level === 'school') { + return { organizationId: a.organizationId, level: 'school', schoolId: a.schoolId, campusId: null, classId: null }; + } + if (a.level === 'campus' || a.level === 'class') { + return { organizationId: a.organizationId, level: 'campus', schoolId: null, campusId: a.campusId, classId: null }; + } + return { organizationId: a.organizationId, level: 'organization', schoolId: null, campusId: null, classId: null }; + } + + const organizationId = orgId(user); + const isGlobal = user?.app_role?.globalAccess === true; + const scope = user?.app_role?.scope; + + if (!isGlobal && scope === ROLE_SCOPES.SCHOOL) { + return { organizationId, level: 'school', schoolId: leaf(user, 'schoolId'), campusId: null, classId: null }; + } + // A class owns no content of its own (only roster + attendance), so class- + // scoped roles (teacher/support_staff) read campus-level content. + if (!isGlobal && (scope === ROLE_SCOPES.CAMPUS || scope === ROLE_SCOPES.CLASS)) { + return { organizationId, level: 'campus', schoolId: null, campusId: leaf(user, 'campusId'), classId: null }; + } + return { organizationId, level: 'organization', schoolId: null, campusId: null, classId: null }; +} + +/** + * Exact-match where for per-tenant content: rows whose owning tenant is exactly + * this level (the leaf id matches and more-specific levels are null). + */ +export function tenantExactWhere(tenant: ActiveTenant): { + organizationId?: string; + schoolId?: string | null; + campusId?: string | null; + classId?: string | null; +} { + const base = tenant.organizationId + ? { organizationId: tenant.organizationId } + : {}; + switch (tenant.level) { + case 'class': + return { ...base, classId: tenant.classId }; + case 'campus': + return { ...base, campusId: tenant.campusId, classId: null }; + case 'school': + return { ...base, schoolId: tenant.schoolId, campusId: null, classId: null }; + case 'organization': + default: + return { ...base, schoolId: null, campusId: null, classId: null }; + } +} + +/** The owning-tenant ids to stamp on a per-tenant content row at create time. */ +export function tenantStamp(tenant: ActiveTenant): { + organizationId: string | null; + schoolId: string | null; + campusId: string | null; + classId: string | null; +} { + return { + organizationId: tenant.organizationId, + schoolId: tenant.level === 'school' ? tenant.schoolId : null, + campusId: tenant.level === 'campus' ? tenant.campusId : null, + classId: tenant.level === 'class' ? tenant.classId : null, + }; +} diff --git a/docs/backlog.md b/docs/backlog.md index 46586c5..49aaed7 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1,45 +1,47 @@ # Backlog — Open Gaps and Remaining Work -Persistent list of deferred work and known gaps so they are not forgotten. **This is the single source for open/remaining work** — the sequenced integration plan is retired now that its phases are done or folded here; its history remains in git. +Persistent list of deferred work and known gaps so they are not forgotten. **This is the single source for open/remaining work**. Completed phase history belongs in git, not in this file. -## Remaining work at a glance +## Current Open Work -- ⛔ **Design-gated (need a customer design decision):** the generic-CRUD management UIs (`users`/`roles`/`permissions` + the other groups), the roles/permissions admin UI, applying `` to specific create/edit/delete affordances, the `MANAGE_*` permissions that depend on it, and the director-creates-classrooms UI (needs a first-class `classrooms` entity, which the backend can build independently). -- **Unblocked, backend-only:** the self-editable-vs-privileged profile-field split; the `classrooms` entity backend; the manager acknowledgment-status report (pending an audience decision); the binary `file` audio-upload path (needs the file-download ownership fix); AI sound generation (swap the `generateSoundRecipe` stub). -- **Dev-machine runs / verification:** `npm install` (sync the OAuth dependency change), `npm run db:reset` (apply the Phase 4 migrations), `npm test`, `npm run test:e2e:content` (incl. the accessibility suite — zero WCAG 2/2.1 A/AA violations across 19 pages), `npm run lint`. +- ⛔ **Design-gated:** generic-CRUD management UIs for the remaining entities, including the SIS-kept academic groups. +- **Audio library:** wire the binary upload affordance; replace the local `generateSoundRecipe` stub with a real AI generation call when credentials/product decision are available. -## Endpoint wiring +## Scope Context -The backend exposes the full generated CRUD surface; the frontend consumes a subset. The SIS prune is complete (owner-approved): `students`, `guardians`, `fee_plans`, `invoices`, `payments`, and `documents` were removed (students/guardians live as **roles**, not SIS entities; the finance cluster is unused; the handbook migrated to `policy_documents`). **Kept for future wiring:** `academic_years`, `assessments`, `assessment_results`, `attendance_records`, `attendance_sessions`, `classes`, `class_enrollments`, `class_subjects`, `subjects`, `grades`, `timetables`, `timetable_periods` (plus `messages`/`message_recipients`, already used by communications). +Hierarchy: **Global → Organization → School → Campus → Class**, plus +**External** for student/guardian accounts. -### To wire during frontend implementation (generic CRUD) +Scope tiers → roles: -Each exposes the identical 9-endpoint shape (`POST /`, `/bulk-import`, `PUT /:id`, `DELETE /:id`, `deleteByIds`, `GET /`, `/count`, `/autocomplete`, `/:id`). Build the management UI + typed `shared/api` module + business hooks for each: +- Global: `super_admin`, `system_admin` +- Organization: `owner`, `superintendent` +- School: `principal`, `registrar` +- Campus: `director`, `office_manager` +- Class: `teacher`, `support_staff` -`academic_years`, `assessments`, `assessment_results`, `attendance_records`, `attendance_sessions`, `campuses` (authenticated `/api/campuses`), `classes`, `class_enrollments`, `class_subjects`, `grades`, `message_recipients`, `messages`, `organizations`, `permissions`, `roles`, `staff`, `subjects`, `timetable_periods`, `timetables`, `users`. +Two scoping modes coexist: -> Wiring `roles` / `permissions` / `users` unblocks the roles/permissions admin UI and lets `` gate real create/edit/delete affordances. +- **Aggregate / subtree:** statistics and attendance roll up through class → campus → school → org. +- **Owned content:** FRAME, policy documents, walkthrough content, and per-tenant catalog rows are owned by the current tenant. Class-scoped users read/write campus-level content; class scope is reserved for roster/attendance-style data. +- **Catalog-specific scope:** `content_catalog` also has school-scoped, org-scoped, and shared/global content types. Truly-global personality and classroom-timer catalogs live in frontend constants, not DB rows. -### Decision-gated extras (keep only if the workflow lands) +Important page/content rules: -- **`auth` extras** (signup, profile, password-reset, verify-email, `email-configured`) — keep only if onboarding/recovery is in scope; otherwise prune. -- **`file`** (`GET /api/file/download`, `POST /api/file/upload/:table/:field`) — both JWT-authenticated; keep if document/avatar/audio upload is on the roadmap, otherwise prune. Upload still needs per-file tenant/ownership before exposing an upload UI. -- **`search`** (`GET /api/search`) — prune unless a search UI is planned. +- Global users have platform/business pages at their own scope and use tenant drill/switching for support/onboarding. +- Leadership Dashboard shows current-scope data for owner/superintendent/principal/registrar/director; platform admins use drill-down for tenant-scoped views. +- Class roles read campus-level educational content; class scope is used for attendance and My Class. +- Messages / Parent Communication is limited to `office_manager`, `teacher`, and `guardian`; contacts are discovered through linked students, and conversations are separated by staff/guardian/student context via `direct_messages`. +- `content_catalog` reads go through authenticated `GET /api/content-catalog/read/:type` and are scoped by content type. -## Cross-cutting open gaps +## Generic CRUD UI Wiring -Authorization / RBAC: -- Dedicated `MANAGE_*` permissions for the manager-only writes (FRAME / walkthrough / communications / content-catalog editing, staff/attendance reports) — currently role-gated in their services; add when the admin UI needs them. -- ⛔ **Blocked on customer design decision:** apply `` to specific create/edit/delete affordances and build the roles/permissions admin UI + the generic-CRUD management pages. The backend endpoints are wired and enforced; the page/UX work is paused pending a design. -- Optionally switch frontend module/route gating from role-based to permission-based (currently role-based, equivalent to the matrix). +The backend exposes generated CRUD for these kept entities, but full list/edit/delete frontend management UI is still design-gated. This is separate from the existing workflow UIs: -Provisioning: -- **Director-creates-classrooms** — needs a first-class `classrooms` entity (backend buildable independently); the classroom management UI is ⛔ blocked on the same customer design decision. -- Define the **self-editable vs privileged profile-field** split (backend contract; unblocked). +- `UserAdminPage` already manages user accounts and uses the seeded `roles` / `permissions` lists as selectors. New roles/permissions are code-seeded, not UI-created. +- `Organizations & Locations` manages existing organizations, schools, campuses, and classes in the current scope; new organizations are created only through Owner creation on `Users`. +- Parent Communication uses `direct_messages`; the generated `messages` / `message_recipients` tables are legacy/reserved CRUD slices, not the current guardian/staff chat implementation. -Files: -- Upload-side per-file ownership + a typed frontend upload client — only after the file UI lands. (Download ownership is already enforced.) +Entities still awaiting generic management UI: -Phase 4 product UIs: -- **Audio library — remaining:** AI sound generation (swap the `generateSoundRecipe` stub for a real model call); the binary `file` upload UI — needs a typed upload client **and** the download-ownership fix (`assertCanDownloadFile` denies any `privateUrl` with no tracked `file` row, but the standalone `/file/upload/:table/:field` path doesn't create one; `recipe`/`url` rows are unaffected). -- **Manager acknowledgment-status report** — backend addition pending the report-audience decision. +`academic_years`, `grades`, `subjects`, `class_enrollments`, `class_subjects`, `assessments`, `assessment_results`, `attendance_sessions`, `attendance_records`, `timetables`, `timetable_periods`, `messages`, `message_recipients`. diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md index ae52445..ec1162b 100644 --- a/docs/deployment-docker.md +++ b/docs/deployment-docker.md @@ -38,7 +38,7 @@ Parameters (env vars set in `docker-compose.yml`, modify as needed): - `SECRET_KEY=local_dev_secret_change_me` - `DB_*` point to the `db` service (Postgres 16, DB/user `app_local`) -- Seed passwords are hardcoded in the seeder (see `CLAUDE.md` for credentials) +- Seed passwords are hardcoded in the seeder (see `AGENTS.md` for credentials) Stop and remove (including DB data): diff --git a/docs/deployment-vm.md b/docs/deployment-vm.md index 5ef38e6..3f0e1ac 100644 --- a/docs/deployment-vm.md +++ b/docs/deployment-vm.md @@ -55,8 +55,9 @@ secrets, not committed to the repository): **From committed `backend/.env`** (not production-level secrets): - `PORT` (optional, defaults to 8080). -- Seed passwords and DB credentials for development are hardcoded in `shared/constants/app.ts` - and `seeders/*`; `.env` is only needed for overrides. +- Seed passwords have safe development defaults in `backend/src/db/seeders/20200430130759-admin-user.ts` + and can be overridden through `SEED_ADMIN_PASSWORD`, `SEED_ADMIN_EMAIL`, and + `SEED_USER_PASSWORD`; `.env` is only needed for overrides. - `backend/src/shared/config/load-env.ts` finds `backend/.env` the same way for both `tsx` and compiled `dist`. diff --git a/frontend/docs/auth-integration.md b/frontend/docs/auth-integration.md index 7febefc..6dfd591 100644 --- a/frontend/docs/auth-integration.md +++ b/frontend/docs/auth-integration.md @@ -22,10 +22,9 @@ Do not add secrets to frontend env files. Vite exposes `VITE_*` values to the br 2. `useAuthSession.signIn` calls `POST /api/auth/signin/local` through `frontend/src/shared/api/auth.ts`. 3. The backend sets an HttpOnly auth cookie and returns the current user profile. 4. `useAuthSession` restores the session with `GET /api/auth/me`. -5. The backend returns the current product profile, including `app_role` (`{ id, name, scope, globalAccess }`), `campus`, `staffProfile`, and `permissions`. The UI role is derived from `app_role.name` (one of the 11 role names); there is no separate `productRole`. -6. UI-facing `StaffProfile` is derived in `frontend/src/business/auth/mappers.ts`. -7. `useAuthSession.signOut` calls `POST /api/auth/signout`; the backend clears the auth cookie. -8. `SignInModal` delegates modal mode, form draft state, validation, and submit workflow to `useAuthModalWorkflow`. +5. The backend returns the current product profile, including `app_role` (`{ id, name, scope, globalAccess }`), direct tenant scope (`organizationId` / `schoolId` / `campusId` / `classId`), `avatar`, `phoneNumber`, and effective `permissions`. The UI role is derived from `app_role.name` (one of the first-class role names); there is no separate `productRole`. +6. `useAuthSession.signOut` calls `POST /api/auth/signout`; the backend clears the auth cookie. +7. `SignInModal` delegates modal mode, form draft state, validation, and submit workflow to `useAuthModalWorkflow`. ## Refresh Tokens @@ -36,13 +35,14 @@ The refresh flow keeps tokens backend-owned: 3. `POST /api/auth/refresh` uses the refresh cookie only, rotates it server-side, sets fresh cookies, and returns the current user profile. 4. `httpClient` performs one controlled refresh-and-retry after an access-expiry `401`. A `403` is treated as **forbidden, not expired** — it surfaces as an `ApiError` (no refresh, no logout) and a global `QueryCache`/`MutationCache` handler toasts it. 5. If refresh fails because both access and refresh credentials are expired or invalid, the business layer clears the user and redirects to `/login`. -6. Non-auth backend failures remain observable errors; no infinite retry and no silent fallback. +6. React Query does not retry `AuthExpiredError` or `401` `ApiError` responses, so an expired session does not fan out into repeated protected API calls. +7. Non-auth backend failures remain observable errors; no infinite retry and no silent fallback. The frontend must not read, store, or receive access or refresh token values. ## Login Route -The app exposes `/login` as the deterministic destination for expired sessions and for unauthenticated visitors. An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) wraps the shell and redirects unauthenticated users to `/login`; the shell is authenticated-only. The previous in-shell guest-preview experience (guest banner, guest role picker, in-shell sign-in) was removed — sign-in happens on `/login` via the retained `SignInModal`. +The app exposes `/login` as the deterministic destination for expired sessions and for unauthenticated visitors. An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) wraps the shell and redirects unauthenticated users to `/login`; the shell is authenticated-only and stays unmounted while `/auth/me` is still resolving. The previous in-shell guest-preview experience (guest banner, guest role picker, in-shell sign-in) was removed — sign-in happens on `/login` via the retained `SignInModal`. Rules: @@ -58,9 +58,9 @@ Rules: Authorization is UX-only on the frontend; the backend remains the sole authority. The user's effective permissions arrive on `CurrentUser.permissions` (role permissions ∪ `custom_permissions`). - **Permission vocabulary:** `frontend/src/shared/auth/permissions.ts` — typed `${VERB}_${ENTITY}` + product-feature permission names mirroring the backend. -- **Selectors + hook:** `frontend/src/business/auth/permissions.ts` (`hasPermission`/`hasAnyPermission`/`hasAllPermissions`) and the `usePermissions()` hook (`frontend/src/hooks/usePermissions.ts`). All are `globalAccess`-aware: the two system roles (`super_admin`/`system_admin`) carry no permission rows but pass every check, mirroring the backend bypass. +- **Selectors + hook:** `frontend/src/business/auth/permissions.ts` (`hasPermission`/`hasAnyPermission`/`hasAllPermissions`) and the `usePermissions()` hook (`frontend/src/hooks/usePermissions.ts`). `system_admin` is permission-driven like the tenant roles; `globalAccess` still keeps its platform-wide scope. Only `super_admin` bypasses the standard management/page checks, and personal workflow permissions listed in `GLOBAL_BYPASS_EXCLUDED_PERMISSIONS` (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`) still require an explicit effective permission. - **Affordance gate:** `` (`frontend/src/components/auth/PermissionGate.tsx`) hides children the user lacks permission for. -- **Route gating:** module/route visibility is role-based via `MODULES[].roles` (it matches the backend permission matrix). `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each role on its first accessible module (so e.g. students/guardians land on the external pages, not the dashboard). +- **Route gating:** module/route visibility is permission- and scope-based via `MODULES[].permissions`, the app-shell scope-tier map, the current effective scope, and the current user's effective `permissions`. `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each user on the first accessible module. When a user drills into or backs out of a tenant scope, the shell replaces an invalid current module route with the first accessible module for the new scope. ## Layering @@ -71,9 +71,9 @@ Authorization is UX-only on the frontend; the backend remains the sole authority ## Deferred Product Onboarding -Registration, company creation, campus creation, user creation, staff profile creation, and profile updates are intentionally deferred. +Registration, company creation, campus creation, user creation, and profile updates are intentionally deferred. -The backend has generated auth signup/profile endpoints, but the customer has not approved the product workflow for creating companies, campuses, users, role assignments, campus assignments, and staff profiles. +The backend has generated auth signup/profile endpoints, but the customer has not approved the product workflow for creating companies, campuses, users, role assignments, and campus assignments. Rules: diff --git a/frontend/docs/campus-attendance-integration.md b/frontend/docs/campus-attendance-integration.md index 28fabc4..a756a96 100644 --- a/frontend/docs/campus-attendance-integration.md +++ b/frontend/docs/campus-attendance-integration.md @@ -34,6 +34,7 @@ Business logic layer: API/data access layer: - `frontend/src/shared/api/campusAttendance.ts` +- `frontend/src/shared/api/staffAttendance.ts` (staff summary and office/staff attendance entry) - `frontend/src/shared/types/campusAttendance.ts` - `frontend/src/shared/constants/campusAttendance.ts` @@ -43,15 +44,46 @@ API/data access layer: - Attendance links save through `PUT /api/campus_attendance/configs/:campusKey`. - Daily campus summaries load from `GET /api/campus_attendance/summaries`. - Daily campus summaries save through `PUT /api/campus_attendance/summaries/:campusKey/:date`. +- The page derives its mode from the effective scope, not from the signed-in role label: + - campus/class effective scope shows campus-only attendance. + - school effective scope aggregates all scoped campus summaries and school staff attendance. + - organization effective scope aggregates all scoped school/campus summaries plus organization staff attendance. +- Users with `FILL_ATTENDANCE` can enter daily student attendance from organization, school, + campus, or class effective scope. Campus/class scope can render Present/Late/Absent controls + per student when a roster is available and derives the aggregate campus summary from those rows. + Late students count as present and increment the tardy count. When no roster is available, the + form falls back to manual aggregate totals. +- Class effective scope resolves the class's parent campus for the campus attendance summary, but + loads the student roster with `users?classId=...` so the classroom form shows only students in + that classroom. +- Organization/school screens render a student attendance rollup table for every scoped child + campus. Each row is prefilled from the campus summary for the selected date; campuses without + child data show empty inputs. Saving writes valid edited rows back to + `campus_attendance_summaries` per campus. Organization and school totals are computed from those + campus rows plus staff attendance reports. +- Aggregate cards follow the tenant hierarchy: organization scope shows school cards, and school + scope shows campus cards. Clicking a child card opens + `/attendance/details/:level/:tenantId`. The details page shows separate tables for that child + scope's student attendance summaries and staff attendance records. +- Organization and school screens also expose office staff attendance entry as a batch table. + Organization office attendance targets organization-owned users without school/campus + assignment. School office attendance targets school-owned users without campus assignment. + Each staff row has Present/Late/Absent controls, and saving writes one record per user through + `PUT /api/staff_attendance/records/:userId/:date`. These records feed the staff attendance + summary. +- Campus screens expose the same staff attendance table for campus-bound and class-scoped staff + inside the campus. +- Staff summary loads from `GET /api/staff_attendance/summary?startDate=today&endDate=today` only when the user has `READ_STAFF_ATTENDANCE_REPORTS`. +- Aggregate views render only campus cards represented by scoped attendance/config rows, because the campus catalog endpoint is not the source of scoped reporting data. - The backend calculates the attendance percentage. - `CampusAttendance.tsx` is a thin composition wrapper. -- CampusAttendance uses typed business hooks/selectors for role access, form state, today, weekly, campus, overall summary calculations, and print report generation. +- CampusAttendance uses typed business hooks/selectors for access, form state, today, weekly, campus, overall summary calculations, and print report generation. - Print report generation escapes dynamic strings before writing report HTML. - Blocked print popups return an explicit print result and show a visible attendance status error. ## Verification -- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations and summary selectors. +- `frontend/src/business/campus-attendance/selectors.test.ts` covers attendance calculations, scope titles, and combined student/staff summary selectors. - `frontend/src/business/campus-attendance/printReport.test.ts` covers printable report generation. - `frontend/src/business/campus-attendance/printReport.test.ts` covers blocked-popup handling for attendance report printing. - `frontend/src/business/campus-attendance/mappers.test.ts` covers API DTO mapping. diff --git a/frontend/docs/classroom-support-integration.md b/frontend/docs/classroom-support-integration.md index 8b3ff86..c3e0d62 100644 --- a/frontend/docs/classroom-support-integration.md +++ b/frontend/docs/classroom-support-integration.md @@ -35,7 +35,7 @@ Shared contracts and UI config: The page reads: -- `GET /api/public/content-catalog/classroom-strategies` +- `GET /api/content-catalog/read/classroom-strategies` The content payload is seeded in: @@ -55,7 +55,9 @@ Each strategy payload supports: ## Behavior - `useClassroomSupportPage` loads strategies through the shared content catalog hook. -- Selectors handle search, category, age, zone, favorites-only filtering, favorite toggling, and the daily strategy selection. +- The app-shell scope selector exposes this module at organization, school, campus, and class effective tiers when the user has `READ_CLASSROOM`. Organization-level users can maintain classroom strategy content for descendant schools and campuses through the same backend-owned content catalog. +- Favorite strategy IDs load and persist through `GET/POST/DELETE /api/user_progress` with `progress_type = classroom_strategy_favorite`. Favorite controls are enabled only when the user is viewing their own scope; parent users drilled into a child tenant do not load or write child-scope favorites. +- Selectors handle search, category, age, zone, favorites-only filtering, and the daily strategy selection. - View components receive a prepared page model and do not call API/data access modules. - Loading, empty, and error states are explicit through `StatePanel`. - The detail modal displays `implementationTip` only when the backend payload provides it. diff --git a/frontend/docs/classroom-timer-integration.md b/frontend/docs/classroom-timer-integration.md index 4e6c7d3..b359341 100644 --- a/frontend/docs/classroom-timer-integration.md +++ b/frontend/docs/classroom-timer-integration.md @@ -18,12 +18,10 @@ sounds, **generated** sounds (`recipe` rows), and **uploaded** audio - View pieces are under `frontend/src/components/classroom-timer/`. The sound picker (`TimerSettingsPanel`) groups sounds by origin — **Built-in / Generated / Uploaded** — for clear structure. -- Backgrounds/presets/tips and the built-in sound metadata are backend-owned - `content_catalog` records loaded via the shared content-catalog API. The +- Backgrounds/presets/tips and the built-in sound metadata are product-static + constants in `frontend/src/shared/constants/classroomTimerContent.ts`. The built-in **sounds themselves** are synthesized in-browser (`business/classroom-timer/audio.ts`, `playBuiltInSound`). -- Missing content-catalog data renders an explicit backend error (no frontend - seed fallback). ## Audio Library @@ -34,23 +32,21 @@ The picker merges the built-ins with the `audio_files` library the `SoundRecipe` shape). - **Hooks**: `useAudioFiles` (list; `retry: false` so a caller without `READ_AUDIO_FILES` silently falls back to the built-ins), `useGenerateAudioFile`, - `useDeleteAudioFile`. `canManageAudioFiles` gates the manage affordances - (director/office_manager/teacher). + `useDeleteAudioFile`. `MANAGE_AUDIO_FILES` gates the manage affordances. - **Playback branches by kind**: `builtin` → `playBuiltInSound(id)`, `recipe` → `playRecipe(recipe)` (`business/classroom-timer/audio-recipe.ts`, pure Web Audio from JSON params), `file`/`url` → `new Audio(url)`. -- **Generate**: managers type a name and click *Generate*; this creates a +- **Generate**: permitted users type a name and click *Generate*; this creates a `recipe` row whose synthesis parameters come from a **local stub** (`business/audio-files/generate.ts`). When an AI key is wired, only that function changes — persistence, playback and the library list are unchanged. An empty name falls back to a generated name. -- **Delete**: managers can remove their own (non-default) library rows. +- **Delete**: permitted users can remove their own (non-default) library rows. ## Tests - `business/classroom-timer/selectors.test.ts` (timer math) -- `business/audio-files/selectors.test.ts` (`canManageAudioFiles`), - `business/audio-files/generate.test.ts` (local recipe stub shape) +- `business/audio-files/generate.test.ts` (local recipe stub shape) ## Verification diff --git a/frontend/docs/communications-integration.md b/frontend/docs/communications-integration.md index 470b7f0..0ce4129 100644 --- a/frontend/docs/communications-integration.md +++ b/frontend/docs/communications-integration.md @@ -2,7 +2,7 @@ ## Purpose -Parent communication, internal alerts, and dashboard upcoming events follow the frontend three-layer architecture. +Messages, internal alerts, and dashboard upcoming events follow the frontend three-layer architecture. ```text View -> Business Logic -> API/Data Access -> Backend @@ -12,7 +12,7 @@ View -> Business Logic -> API/Data Access -> Backend View layer: -- `frontend/src/components/parent-communication/ParentCommunicationModule.tsx` +- `frontend/src/pages/modules/MessagesPage.tsx` - `frontend/src/components/internal-alerts/InternalAlertsModule.tsx` - `frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx` - `frontend/src/components/frameworks/MoreModules.tsx` as a module export hub @@ -28,19 +28,27 @@ Business logic layer: API/data access layer: - `frontend/src/shared/api/communications.ts` +- `frontend/src/shared/api/directMessages.ts` - `frontend/src/shared/types/communications.ts` - `frontend/src/shared/constants/communications.ts` ## Behavior -- Parent message logs load from `GET /api/communications/parent-messages`. -- Parent messages save through `POST /api/communications/parent-messages`. +- The active Messages module uses `GET /api/direct_messages/contacts`, `GET /api/direct_messages/conversations`, `GET /api/direct_messages/thread/:otherUserId?studentId=...`, and `POST /api/direct_messages/send`. +- Contacts are discovered through linked students: + - Guardians see each linked student's teacher and office manager. + - Teachers see guardians for students in their class. + - Office managers see guardians for students on their campus. +- A conversation is separated by `otherUserId + studentId`, so the same two users can have distinct threads for different students. - Internal alerts load from `GET /api/communications/events`. -- Director/superintendent users create alerts through `POST /api/communications/events`. +- Users with `MANAGE_INTERNAL_COMM` create alerts through `POST /api/communications/events`. +- Alert creators and in-scope `MANAGE_INTERNAL_COMM` managers can edit alerts, delete wrongly-created alerts, or cancel alerts. Cancel creates a new cancellation notification for the same audience; delete removes the original alert without notifying users. +- The Internal Alerts form sends exact audience targets. Platform scope can target system admins, all users, or selected organization leadership. Organization scope can target organization leadership, selected school leadership, or selected campus staff. School scope can target school leadership or selected campus staff. Campus scope can target its own campus staff. Class scope cannot create alerts. +- Alert lists are server-filtered. Platform root shows platform alerts plus tenant-target alerts created by the current platform user; admins see other users' tenant alerts by drilling into that tenant. Parent tenant scopes see descendant-target alerts they created or manage: organizations see their school/campus target alerts, and schools see their campus target alerts. Parent alerts do not cascade down automatically. A campus alert is also visible to class-scope users because class content is read at campus level. - Dashboard upcoming events read the same backend event data as the internal alerts module through `useDashboardPage`. - Safety protocols are loaded through the content catalog backend contract and rendered in a focused view module. -- Approved parent templates and event display constants live in shared constants. -- Parent communication, internal alerts, and safety protocols use shared UI primitives for buttons, form controls, and status panels. +- Event display constants live in shared constants. +- Messages, internal alerts, and safety protocols use shared UI primitives for buttons, form controls, and status panels. ## Remaining Related Work diff --git a/frontend/docs/community-service.md b/frontend/docs/community-service.md index 64579d3..0a48b57 100644 --- a/frontend/docs/community-service.md +++ b/frontend/docs/community-service.md @@ -36,7 +36,7 @@ API/data access layer: ## Behavior - The framework component is a thin wrapper around `useCommunityService`. -- Organization records load from `GET /api/public/content-catalog/community-organizations`. +- Organization records load from `GET /api/content-catalog/read/community-organizations`. - Shared constants own partnership labels/classes, age group labels, and category icon keys. - Business selectors own category extraction, typed select value normalization, organization filtering, and stats. - Business hook owns content loading, search text, category/type/age filters, expanded organization state, saved organization state, and filter panel state. diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index 5f520f0..d94ef1b 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -2,9 +2,9 @@ ## Purpose -Frontend content catalogs are loaded from the backend through the shared content catalog API. +Editable frontend content catalogs are loaded from the backend through the shared content catalog API. -Editable runtime product/content records must not live in `frontend/src/shared/constants/`. The frontend may keep only non-secret config, query keys, labels, timing values, stable training copy, and style tokens. +Editable runtime product/content records must not live in `frontend/src/shared/constants/`. The frontend may keep only non-secret config, query keys, labels, timing values, stable training copy, product-static catalogs, and style tokens. ## Files @@ -18,7 +18,7 @@ Editable runtime product/content records must not live in `frontend/src/shared/c Runtime read: -- `GET /api/public/content-catalog/:contentType` +- `GET /api/content-catalog/read/:contentType` Authenticated management: @@ -41,15 +41,15 @@ catalog only once the user types (see `top-bar-integration.md`). - regulation zones - zones of regulation page content - dashboard quote, compliance items, and sign of the week -- parent message templates - community organizations - vocational opportunities - emotional intelligence assessment content, weekly focus, and team content -- personality quiz questions and personality type directory -- personality workplace sidebar content - ESA funding content -- classroom timer backgrounds, sounds, presets, and tips -- personality quiz intro feature cards + +Product-static content that intentionally does **not** use content catalog: + +- personality quiz questions, personality type directory, quiz intro feature cards, and workplace sidebar content (`frontend/src/shared/constants/personalityStaticContent.ts`) +- classroom timer backgrounds, built-in sound metadata, presets, and tips (`frontend/src/shared/constants/classroomTimerContent.ts`) ## Error Handling @@ -89,10 +89,6 @@ The content e2e suite lives in `frontend/tests/e2e/content-catalog.seeded.e2e.ts Minimum backend seed set for content e2e: -- `classroom-timer-backgrounds` -- `classroom-timer-sounds` -- `classroom-timer-presets` -- `classroom-timer-tips` - `classroom-strategies` - `sign-language-items` - `sign-language-page-content` @@ -102,7 +98,7 @@ Minimum backend seed set for content e2e: - `dashboard-compliance-items` - `dashboard-sign-of-week` -The seeded e2e suite first verifies this minimum set through `GET /api/public/content-catalog/:contentType`, then renders high-value content-backed routes and asserts UI text taken from the live backend response. It does not import backend seed files or duplicate seed records in frontend tests. +The seeded e2e suite first verifies this minimum set through `GET /api/content-catalog/read/:contentType`, then renders high-value content-backed routes and asserts UI text taken from the live backend response. The classroom timer check renders product-static content and does not require backend content-catalog rows. The suite does not import backend seed files or duplicate seed records in frontend tests. ## Editable Quiz Content @@ -123,6 +119,7 @@ Regulation zone records, behaviors, strategies, matching signs, QBS safety conne ## Editable Dashboard Content Dashboard quotes, compliance items, and sign-of-week content are part of the `dashboard-encouraging-quotes`, `dashboard-compliance-items`, and `dashboard-sign-of-week` content catalog payloads. The dashboard business layer renders these payloads and does not keep dashboard content records in shared constants. +These dashboard catalog rows are seeded per tenant at organization, school, and campus scope so owner/superintendent/principal/registrar/director dashboards and platform-admin drill-down views all read backend-owned content. ## Editable ESA Funding Content diff --git a/frontend/docs/dashboard-integration.md b/frontend/docs/dashboard-integration.md index 4a08f40..7d167c1 100644 --- a/frontend/docs/dashboard-integration.md +++ b/frontend/docs/dashboard-integration.md @@ -41,21 +41,21 @@ Shared contracts and UI config: Content catalog: -- `GET /api/public/content-catalog/dashboard-encouraging-quotes` -- `GET /api/public/content-catalog/dashboard-compliance-items` -- `GET /api/public/content-catalog/dashboard-sign-of-week` +- `GET /api/content-catalog/read/dashboard-encouraging-quotes` +- `GET /api/content-catalog/read/dashboard-compliance-items` +- `GET /api/content-catalog/read/dashboard-sign-of-week` Feature APIs: - F.R.A.M.E. entries through `useFrameEntries` - Communication events through `useCommunicationEvents` -- Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (campus-staff roles only; see `zone-checkin-integration.md`) +- Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (explicit `ZONE_CHECKIN` only; see `zone-checkin-integration.md`) ## Behavior - `useDashboardPage` composes all dashboard data sources into one page model. - The hero's "Week of …" uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — the same canonicalization as the F.R.A.M.E. week picker and the safety-quiz week. -- Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and role-filtered quick actions. +- Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and quick actions filtered by the current scoped module set. - View components receive prepared props and do not call API/data access modules. - Loading, empty, and error states remain explicit for each dashboard section. - The existing `Dashboard` props API is preserved while the framework component becomes a thin wrapper. diff --git a/frontend/docs/director-dashboard-integration.md b/frontend/docs/director-dashboard-integration.md index e5b406c..5983c38 100644 --- a/frontend/docs/director-dashboard-integration.md +++ b/frontend/docs/director-dashboard-integration.md @@ -2,7 +2,7 @@ ## Purpose -Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, QBS safety quiz, and staff attendance data. +Director dashboard follows the frontend three-layer architecture and aggregates backend-backed FRAME, QBS safety quiz, staff attendance, and policy acknowledgment data. ```text View -> Business Logic -> API/Data Access -> Backend @@ -26,6 +26,7 @@ API/data access layer: - `frontend/src/shared/api/frame.ts` - `frontend/src/shared/api/safetyQuizResults.ts` - `frontend/src/shared/api/staffAttendance.ts` +- `frontend/src/shared/api/policyAcknowledgments.ts` Constants: @@ -37,6 +38,7 @@ Constants: - FRAME entries load through `useFrameEntries`. - Safety quiz results load through `useSafetyQuizResults`. - Staff attendance records and summary load through staff attendance business hooks. +- Policy acknowledgment summary loads through `usePolicyAcknowledgmentReport`. - Overview metrics, risk areas, and FRAME previews are derived in business selectors. - View components use shared `Button`, `Table`, `StatePanel`, and `ModuleHeader` primitives. - Loading, empty, and error states are explicit. diff --git a/frontend/docs/esa-funding-integration.md b/frontend/docs/esa-funding-integration.md index e59edec..e06d7b1 100644 --- a/frontend/docs/esa-funding-integration.md +++ b/frontend/docs/esa-funding-integration.md @@ -44,7 +44,7 @@ Shared contracts: The page reads: -- `GET /api/public/content-catalog/esa-funding-content` +- `GET /api/content-catalog/read/esa-funding-content` Content payload is seeded in: diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index 74de46e..d09e958 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -66,6 +66,13 @@ Business logic must not: Shared business helpers should be used for repeated cross-module mechanics. `frontend/src/shared/business/queryMutations.ts` centralizes React Query mutation invalidation, `frontend/src/shared/business/apiListRows.ts` centralizes `ApiListResponse.rows` extraction and mapping, and `frontend/src/shared/business/queryState.ts` centralizes multi-query loading/error aggregation. +Personal persisted states (saved quiz results, acknowledgments, learned signs, +and zone check-ins) must be shown and mutated only when the user is viewing +their own scope. Use `canPersistPersonalScopeResults(ownTenant, +selectedTenant)` before rendering "Saved", "Acknowledged", "Progress Saved", or +similar badges, and before triggering personal-result mutations while a parent +user is drilled into a child tenant. + ## Layer 3: API/Data Access Location: @@ -159,9 +166,9 @@ Rules: - `AppRouter.tsx` uses `useRoutes(appRoutes)`. - `APP_ROUTE_PATHS` owns path constants and module route metadata maps each `ModuleId` to exactly one route path. - The browser URL is the source of truth for the active product module. -- Sidebar, footer, dashboard actions, and other module navigation should navigate by route path instead of storing active module state. +- Sidebar, footer, dashboard actions, top-bar search, and other module navigation must use the current scoped module set from the app-shell business layer; do not navigate to a module that is unavailable in the effective scope. - `/login` remains the deterministic destination for expired access plus expired refresh sessions. -- Restricted module routes redirect to `/dashboard`. +- Restricted direct module URLs render the 404 page. When the effective scope changes through tenant drill-down/back-to-scope, the shell replaces an invalid current module route with the first route available in the new scoped module set. ## Update Rule @@ -190,8 +197,8 @@ The active frontend already has: - Theme names, default theme values, CSS class names, and media query constants under `frontend/src/shared/constants/theme.ts`; global light/dark CSS tokens remain in `frontend/src/index.css` and Tailwind maps to those variables in `frontend/tailwind.config.ts`. - React Query keys, UI timing values, storage keys, and sidebar runtime constants live in dedicated files under `frontend/src/shared/constants/`. - Auth/profile session logic under `frontend/src/business/auth/`, with `AuthContext` acting as a thin provider. The auth transport is backend-owned HttpOnly cookie auth documented in `backend/docs/cookie-auth.md` and `frontend/docs/auth-integration.md`. -- Frontend authorization is permission-aware and UX-only (the backend is the sole authority): a typed permission catalog (`frontend/src/shared/auth/permissions.ts`), `globalAccess`-aware selectors + the `usePermissions()` hook (`frontend/src/hooks/usePermissions.ts`), and the `` affordance gate (`frontend/src/components/auth/PermissionGate.tsx`). The UI role derives from `app_role.name` (the 11 first-class roles); there is no `productRole`. -- An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) gates the shell — unauthenticated users redirect to `/login`; `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each role on its first accessible module. The previous in-shell guest-preview experience was removed. +- Frontend authorization is permission-aware and UX-only (the backend is the sole authority): a typed permission catalog (`frontend/src/shared/auth/permissions.ts`), `globalAccess`-aware selectors + the `usePermissions()` hook (`frontend/src/hooks/usePermissions.ts`), and the `` affordance gate (`frontend/src/components/auth/PermissionGate.tsx`). The UI role derives from `app_role.name` (the first-class roles); there is no `productRole`. +- An `AuthGuard` (`frontend/src/app/AuthGuard.tsx`) gates the shell — unauthenticated users redirect to `/login`; `ModuleRouteGuard` renders the 404 page for a forbidden direct URL; `IndexRedirect` lands each user on the first permission- and scope-accessible module. Scope changes normalize the current module route to the first module available in the new effective scope. The previous in-shell guest-preview experience was removed. - App shell state, access selection, campus display lookup, mobile overlay visibility, shell outlet context, and prepared Sidebar/TopBar/Footer props live under `frontend/src/business/app-shell/`. The shared shell layout remains a thin view composition in `frontend/src/components/AppLayout.tsx`. - Top bar shell state under `frontend/src/business/top-bar/`, with search, badges, notifications, and profile menu composition split under `frontend/src/components/top-bar/`. - FRAME entries under `frontend/src/business/frame/`, with typed API calls in `frontend/src/shared/api/frame.ts` and explicit empty/error states in the view. diff --git a/frontend/docs/index.md b/frontend/docs/index.md index 1554718..c7e18ea 100644 --- a/frontend/docs/index.md +++ b/frontend/docs/index.md @@ -2,7 +2,7 @@ ## Start Here -- Repository working rules: [`../../CLAUDE.md`](../../CLAUDE.md) +- Repository working rules: [`../../AGENTS.md`](../../AGENTS.md) - Frontend architecture: [`frontend-architecture.md`](frontend-architecture.md) - Object router rules: [`object-router.md`](object-router.md) - Error handling: [`error-handling.md`](error-handling.md) @@ -29,7 +29,7 @@ Read the repository rules first, then use the frontend architecture document as ## Shell And Navigation -- [`sidebar-integration.md`](sidebar-integration.md): sidebar navigation, role access, and campus branding. +- [`sidebar-integration.md`](sidebar-integration.md): sidebar navigation, permission/scope access, and campus branding. - [`top-bar-integration.md`](top-bar-integration.md): top bar search, notifications, and profile menu. ## Product Slices diff --git a/frontend/docs/object-router.md b/frontend/docs/object-router.md index 557962f..dda3945 100644 --- a/frontend/docs/object-router.md +++ b/frontend/docs/object-router.md @@ -34,5 +34,5 @@ Top-level frontend URL routes are declared through React Router object configura - Reuse `APP_ROUTE_PATHS.login` for auth-expired redirects. - Add route-config and module-route metadata tests when routes change. - New product modules must define a route path and a lazy page adapter. -- Restricted product routes should redirect to `/dashboard` unless a specific access-state UX is implemented and tested. +- Restricted direct product routes should render the 404 page. Scope-change navigation may replace an invalid current module route with the first accessible route for the new effective scope. - Use object routes as the default pattern unless React Router data APIs require a later move to `createBrowserRouter`. diff --git a/frontend/docs/personality-catalog.md b/frontend/docs/personality-catalog.md index 1bb6675..a414fed 100644 --- a/frontend/docs/personality-catalog.md +++ b/frontend/docs/personality-catalog.md @@ -2,12 +2,19 @@ ## Purpose -Static emotional-intelligence personality quiz content lives in `frontend/src/shared/constants/personalityCatalog.ts`. +Static emotional-intelligence personality quiz content lives in `frontend/src/shared/constants/personalityStaticContent.ts`. Pure catalog types and helper functions live in `frontend/src/shared/constants/personalityCatalog.ts`. ## Contents +`personalityStaticContent.ts`: + - `PERSONALITY_QUIZ_QUESTIONS` - `PERSONALITY_TYPES` +- `PERSONALITY_QUIZ_FEATURES` +- `PERSONALITY_WORKPLACE_CONTENT` + +`personalityCatalog.ts`: + - `calculateMBTI` - `getPersonalityType` - static catalog types for quiz questions and personality descriptions diff --git a/frontend/docs/personality-integration.md b/frontend/docs/personality-integration.md index 39d7bdc..fbecad8 100644 --- a/frontend/docs/personality-integration.md +++ b/frontend/docs/personality-integration.md @@ -47,6 +47,10 @@ API/data access layer: - The current user's saved personality result loads from `GET /api/personality_quiz_results/me`. - Quiz completion saves through `PUT /api/personality_quiz_results/me`. +- Saved-result loading, saved-result badges, and result mutations are enabled + only in the user's own scope. A parent user drilled into a child tenant may + complete the quiz for immediate review, but the UI does not claim that the + result was saved and the backend does not create reportable child-scope rows. - Director and superintendent aggregate distribution loads from `GET /api/personality_quiz_results/distribution`. - Backend errors are surfaced as UI error states instead of being swallowed. - `EmotionalIntelligence.tsx` is a thin composition wrapper. @@ -58,7 +62,7 @@ API/data access layer: - Personality business hooks are split by workflow: backend query/mutation hooks, directory workflow, EI page workflow, and quiz workflow. Imports use the workflow-specific files directly; there is no legacy re-export surface. - EI questions, topics, growth tips, MBTI dimensions, and workplace tips live in shared constants. - Personality type directory records load from the backend content catalog. -- The frontend does not write personality type to staff profile records. +- The frontend does not write personality type to user employment fields. ## Verification diff --git a/frontend/docs/policies-integration.md b/frontend/docs/policies-integration.md index 8f2c182..ee7e4d3 100644 --- a/frontend/docs/policies-integration.md +++ b/frontend/docs/policies-integration.md @@ -2,7 +2,8 @@ ## Purpose -Two pages — **Handbook & Policies** and **Safety Protocols** — are backed by one +Three pages — **Handbook & Policies**, **Safety Protocols**, and +**Acknowledgments** — are backed by one unified store, `policy_documents` (it replaced the former generic `documents` API, which has been removed). `category` selects the page (`handbook_policy` vs `safety_protocol`); `tag` carries the finer sub-category (the handbook's @@ -23,6 +24,14 @@ Safety Protocols: (+ `SafetyProtocolForm.tsx`, `SafetyDynamicListEditor.tsx`) - Business: `frontend/src/business/safety-protocols/{hooks,mappers,selectors,types}.ts` +Acknowledgments: + +- Page: `frontend/src/pages/modules/AcknowledgmentsPage.tsx` +- Business/API: `usePolicyAcknowledgmentReport` in + `frontend/src/business/policies/hooks.ts`, backed by + `frontend/src/shared/api/policyAcknowledgments.ts` +- Dashboard: `DirectorDashboard` includes the report summary as an overview card. + Shared: - API layer: `frontend/src/shared/api/policyDocuments.ts`, @@ -39,6 +48,8 @@ Shared: `DELETE /api/policy_documents/:id` - `GET /api/policy_acknowledgments` (caller's own), `POST /api/policy_acknowledgments` (`{ data: { policyDocumentId } }` → acknowledges the current version) +- `GET /api/policy_acknowledgments/report` (manager report for current tenant + scope: summary totals, per-document completion, and per-staff statuses) A `policy_documents` row carries: `title`, `body`, `category`, `tag`, `author` (display name of the creating user, server-set), `steps` + @@ -60,8 +71,11 @@ change), `active`, tenant `organizationId` + nullable `campusId`. `useAcknowledgePolicy`, shared by both pages) — it replaced the former session-local set. Re-acknowledgment is required after a version bump and is idempotent within a version. -- Management is gated by `canManagePolicies` / `canManageSafetyProtocols` - (owner/superintendent/director/office_manager), mirroring the backend grant. +- **Acknowledgment report** is available to owner, superintendent, principal, + registrar, and director. Super/system admins can read it only while drilled + into a tenant. The report counts active director/office_manager/teacher/ + support_staff accounts in the current scope. +- Management is gated by effective policy-document permissions, mirroring the backend grant. - Both pages are seeded from `20260611050000-policy-documents-seed.ts`. ## Tests diff --git a/frontend/docs/safety-quiz-integration.md b/frontend/docs/safety-quiz-integration.md index 3cfbc14..ca4b39c 100644 --- a/frontend/docs/safety-quiz-integration.md +++ b/frontend/docs/safety-quiz-integration.md @@ -40,8 +40,14 @@ Constants: ## Behavior - Quiz submission uses `POST /api/safety_quiz_results`. +- Behavior Management is available at organization, campus, and class effective + tiers when the user has `READ_QBS`. +- Result-saving UI and mutation are enabled only in the user's own scope. A + parent user drilled into a child tenant can complete the quiz for immediate + feedback, but no "saved" badge is shown and no reportable child-scope result is + created. - Staff completion and director dashboard rows load from `GET /api/safety_quiz_results`. -- QBS quiz content loads from `GET /api/public/content-catalog/safety-qbs-quiz`. +- QBS quiz content loads from `GET /api/content-catalog/read/safety-qbs-quiz`. - Directors and superintendents can edit the QBS quiz content through the authenticated content catalog endpoint `PUT /api/content-catalog/safety-qbs-quiz`. - Editable QBS quiz payloads are JSON-validated in the business layer before saving. - Compliance views render empty and error states explicitly instead of substituting static staff rows. diff --git a/frontend/docs/shared-app-types.md b/frontend/docs/shared-app-types.md index 224f228..3c86802 100644 --- a/frontend/docs/shared-app-types.md +++ b/frontend/docs/shared-app-types.md @@ -11,7 +11,6 @@ UI-facing product types live in `frontend/src/shared/types/app.ts`. - `UserRole` - `CampusId` - `CampusInfo` -- `StaffProfile` - `ModuleId` - `Module` - `ZoneColor` diff --git a/frontend/docs/sidebar-integration.md b/frontend/docs/sidebar-integration.md index bf121dc..b6e7971 100644 --- a/frontend/docs/sidebar-integration.md +++ b/frontend/docs/sidebar-integration.md @@ -39,7 +39,8 @@ Shared config: - `useAppShell` resolves backend-owned campus branding into `campusInfo` before passing it to Sidebar. - Selectors handle module access, role labels, and campus initials outside JSX. - Sidebar navigation calls the app-shell module navigation action, which navigates to the selected module route path. -- The active sidebar item is derived from the current URL route through the app-shell business layer. +- The active sidebar item and sidebar list are derived from the current URL route and the app-shell scoped module set. The same scoped module set is reused by top-bar search, footer links, dashboard quick actions, and scope-change route normalization so navigation surfaces stay consistent. +- Classroom Support is scoped to organization, school, campus, and class effective tiers. Behavior Management is scoped to organization, campus, and class effective tiers. Both still require their module permissions through the app-shell scoped module selector. - View components receive a prepared page model and do not call API/data access modules. - Sidebar navigation uses the local `Button` primitive instead of raw controls. @@ -47,4 +48,4 @@ Shared config: - Do not add seeded campus records or module content to Sidebar components. - Campus branding is backend-owned data and should keep flowing through `campusInfo`. -- Module IDs, route paths, and role access metadata stay centralized in `frontend/src/shared/constants/appData.ts` until backend-owned module configuration is introduced. +- Module IDs, route paths, and permission metadata stay centralized in `frontend/src/shared/constants/appData.ts`; scope-tier visibility is owned by the app-shell business selectors until backend-owned module configuration is introduced. diff --git a/frontend/docs/sign-language-integration.md b/frontend/docs/sign-language-integration.md index abb4d37..a12d266 100644 --- a/frontend/docs/sign-language-integration.md +++ b/frontend/docs/sign-language-integration.md @@ -41,8 +41,8 @@ Shared contracts and UI config: The page reads content from: -- `GET /api/public/content-catalog/sign-language-items` -- `GET /api/public/content-catalog/sign-language-page-content` +- `GET /api/content-catalog/read/sign-language-items` +- `GET /api/content-catalog/read/sign-language-page-content` Learned progress uses: @@ -57,6 +57,10 @@ Content payloads are seeded in: ## Behavior - `useSignLanguagePage` loads sign items, page content, and learned sign progress. +- Learned progress is a personal persisted state. When a parent-scope user is + drilled into a child tenant, the page still shows sign content, but it does + not load/write learned-sign progress or render "Progress Saved" / "Learned" + affordances. - Selectors handle category counts, search/category filtering, progress percentage, video duration, filter normalization, and YouTube search URL construction. - View components receive a prepared page model and do not call API/data access modules. - The video modal uses `useSignLanguageVideoModalState` for GIF/video mode, GIF loading state, and step-guide expansion. diff --git a/frontend/docs/static-app-data.md b/frontend/docs/static-app-data.md index 2428f56..9cbe13b 100644 --- a/frontend/docs/static-app-data.md +++ b/frontend/docs/static-app-data.md @@ -18,4 +18,4 @@ The file contains only non-secret frontend configuration and static UI assets: - Move newly persisted workflows to typed backend APIs and business hooks. - Further domain split is allowed when UI configuration becomes large enough to justify its own shared constant file. - Campus records and branding are backend-owned and are loaded through `GET /api/public/campuses`; frontend campus helpers must not define campus rows, names, mascot labels, descriptions, or per-campus branding. -- Product/content catalogs are backend-owned and are loaded through `GET /api/public/content-catalog/:contentType`. +- Editable/scoped product content catalogs are backend-owned and are loaded through authenticated `GET /api/content-catalog/read/:contentType`. Truly global static catalogs, such as classroom-timer presets and personality quiz content, live in dedicated shared constant files. diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index b736db7..d8c68cc 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -81,7 +81,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. -The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support. +The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support and explicit personal-workflow exclusions. The personality selector tests caught and now guard against a grouping defect where S/J and S/P types were classified from the third code character instead of the fourth code character. @@ -121,9 +121,10 @@ Prerequisites: - backend database seeders have run; - backend server is running at `VITE_BACKEND_API_URL`. -Test credentials are hardcoded (see `CLAUDE.md` for the full list): +Test credentials are hardcoded (see `AGENTS.md` for the full list): - Admin: `admin@flatlogic.com` / `flatlogicAdmin123!` - Users: `@flatlogic.com` / `flatlogicUser123!` +- Secondary tenant users: `2@flatlogic.com` / `flatlogicUser123!` The seeded suite verifies: - Minimum content catalog seed set through the public backend API @@ -134,7 +135,7 @@ The seeded suite verifies: - **Product workflows**: Director FRAME entries and staff progress tracking persist correctly (incl. server-side Sunday normalization of the FRAME `week_of`) - **Policy acknowledgments**: document create/persist, manage-vs-read RBAC, per-version (idempotent) acknowledgment, and external-role lockout - **Audio library**: `file`/`url`/`recipe` create/persist, same-campus read, kind/content validation, `support_staff` read-only, and external-role lockout -- **Daily Zone check-in**: campus-staff record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout +- **Daily Zone check-in**: explicit-permission record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout ## Accessibility E2E Coverage diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index 8ead619..db2cac3 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -36,15 +36,18 @@ Shared config: - `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`. - `useTopBarPage` owns profile menu state, notifications menu state, search query state, and sign-out error state. +- Notification nudges include the daily Emotional Zone reminder only when the + user has explicit `ZONE_CHECKIN`; that personal workflow permission is not + implied by `globalAccess`. - Selectors handle initials, campus label fallback, shared role labels, and unread notification count. -- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (local, role-filtered via `getAccessibleModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for accessible modules) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here. +- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here. - View components receive a prepared page model and do not call API/data access modules. - Profile and settings menu items are explicitly disabled until product workflows exist, instead of rendering silent no-op buttons. ## Tests - `business/top-bar/selectors.test.ts` (notification builder + zones `href`), - `business/top-bar/search.test.ts` (module role-filtering + content matching + + `business/top-bar/search.test.ts` (module permission-filtering + content matching + combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal). ## Data Ownership Rules diff --git a/frontend/docs/user-progress-integration.md b/frontend/docs/user-progress-integration.md index 20a80a5..8699bd5 100644 --- a/frontend/docs/user-progress-integration.md +++ b/frontend/docs/user-progress-integration.md @@ -2,11 +2,12 @@ ## Purpose -User progress follows the frontend three-layer architecture for **sign language** -learned-progress. (The daily Zone check-in also persists in `user_progress` -server-side, but the frontend reads it through the dedicated `/api/zone_checkins` -slice — see [`zone-checkin-integration.md`](zone-checkin-integration.md) — not -through this generic client.) +User progress follows the frontend three-layer architecture for current-user +personal state, including **sign language** learned progress and Classroom +Support favorite strategies. The daily Zone check-in also persists in +`user_progress` server-side, but the frontend reads it through the dedicated +`/api/zone_checkins` slice — see [`zone-checkin-integration.md`](zone-checkin-integration.md) — +not through this generic client. ```text View -> Business Logic -> API/Data Access -> Backend @@ -17,14 +18,18 @@ View -> Business Logic -> API/Data Access -> Backend View layer: - `frontend/src/components/frameworks/SignLanguage.tsx` +- `frontend/src/components/frameworks/ClassroomSupport.tsx` - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageVideoModal.tsx` +- `frontend/src/components/classroom-support/` Business logic layer: - `frontend/src/business/dashboard/hooks.ts` - `frontend/src/business/dashboard/selectors.ts` - `frontend/src/business/sign-language/hooks.ts` - `frontend/src/business/sign-language/selectors.ts` +- `frontend/src/business/classroom-support/hooks.ts` +- `frontend/src/business/classroom-support/selectors.ts` - `frontend/src/business/user-progress/hooks.ts` - `frontend/src/business/user-progress/mappers.ts` - `frontend/src/business/user-progress/types.ts` @@ -43,9 +48,16 @@ Constants: - Learned sign IDs load from `GET /api/user_progress?progress_type=sign_learned`. - Marking a sign learned uses `POST /api/user_progress`. - Unmarking a sign uses `DELETE /api/user_progress/by-item`. +- Classroom strategy favorite IDs load from + `GET /api/user_progress?progress_type=classroom_strategy_favorite`. +- Saving a classroom strategy favorite uses `POST /api/user_progress`; removing it uses + `DELETE /api/user_progress/by-item`. - The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. +- The Classroom Support page combines favorite progress with backend content catalog records in `useClassroomSupportPage`. - Views render explicit backend errors from React Query state. - User progress ownership is derived by the backend from the authenticated session. +- Personal progress reads/writes are enabled only in the user's own scope. Parent users drilled into + a child tenant do not load/write favorite or learned-progress rows. ## Remaining Related Work diff --git a/frontend/docs/vocational-opportunities.md b/frontend/docs/vocational-opportunities.md index fe93fe8..49256dc 100644 --- a/frontend/docs/vocational-opportunities.md +++ b/frontend/docs/vocational-opportunities.md @@ -36,7 +36,7 @@ API/data access layer: ## Behavior - The framework component is a thin wrapper around `useVocationalOpportunities`. -- Opportunity records load from `GET /api/public/content-catalog/vocational-opportunities`. +- Opportunity records load from `GET /api/content-catalog/read/vocational-opportunities`. - Shared constants own zip search configuration, category previews, category icon keys, and style tokens. - Business selectors own zip normalization, local search result derivation, filtering, category lists, and stats. - Business hook owns content loading, zip input, search state, search text, category filter, expanded card state, and saved opportunity state. diff --git a/frontend/docs/zone-checkin-integration.md b/frontend/docs/zone-checkin-integration.md index 70639f6..78f7c90 100644 --- a/frontend/docs/zone-checkin-integration.md +++ b/frontend/docs/zone-checkin-integration.md @@ -9,9 +9,9 @@ nudge when an eligible user has not checked in today. ## Backend contract -`/api/zone_checkins` (requires `ZONE_CHECKIN` — the four campus staff roles). The -client never computes the date; "today" is the campus-local date computed -server-side from `campuses.timezone`. +`/api/zone_checkins` requires explicit `ZONE_CHECKIN`. This personal workflow +permission is not implied by `globalAccess`. The client never computes the date; +"today" is the campus-local date computed server-side from `campuses.timezone`. - `GET /today` → `{ date, zone, isCheckedInToday }` - `POST /` `{ data: { zone } }` → record today's zone (upsert) @@ -29,11 +29,11 @@ server-side from `campuses.timezone`. ## Behavior -- **Eligibility/nudge gating** is role-based (`canZoneCheckIn` — the four campus - staff roles), mirroring the backend grant. The dashboard card and the zones-page - section render only for eligible roles; the nudge (red "Not checked in" badge, - reminder banner, and notification) shows when an eligible user hasn't checked in - today. +- **Eligibility/nudge gating** requires the effective `ZONE_CHECKIN` permission. + Seed data grants it only to the campus staff workflow audience + (`director`, `office_manager`, `teacher`, `support_staff`), but custom + permissions can extend or remove it per user. Global/full-access leadership + users do not see self-state nudges unless they receive explicit `ZONE_CHECKIN`. - **Dashboard**: the card is wired through `useDashboardPage` (which exposes `showZoneCheckIn` + `needsZoneCheckIn`) with an optimistic shell value for snappy selection. @@ -44,10 +44,9 @@ server-side from `campuses.timezone`. notifications store. The notification carries an `href` (`APP_ROUTE_PATHS.zones`); clicking it navigates to `/zones-of-regulation` (a react-router `Link`) and closes the dropdown. -- The `useTodayZoneCheckIn` `error` surfaces **only** save/clear failures; the - today-load query can 403 for an ineligible caller and must not render as an - error in the widget. React Query dedupes the `/today` fetch across all three - surfaces. +- `useTodayZoneCheckIn` is disabled for users without `ZONE_CHECKIN`. Its `error` surfaces + **only** save/clear failures; non-eligible users should not trigger the + `/today` request. ## Tests diff --git a/frontend/docs/zones-of-regulation-integration.md b/frontend/docs/zones-of-regulation-integration.md index 2f4f7a5..acff0ef 100644 --- a/frontend/docs/zones-of-regulation-integration.md +++ b/frontend/docs/zones-of-regulation-integration.md @@ -42,8 +42,8 @@ Shared contracts and UI config: The page reads: -- `GET /api/public/content-catalog/regulation-zones` -- `GET /api/public/content-catalog/zones-of-regulation-page-content` +- `GET /api/content-catalog/read/regulation-zones` +- `GET /api/content-catalog/read/zones-of-regulation-page-content` Content payloads are seeded in: @@ -55,7 +55,7 @@ Content payloads are seeded in: - Selectors handle expanded-zone toggling, selected-zone lookup, safety connection lookup, and active-tab wording. - View components receive a prepared page model and do not call API/data access modules. - Loading and error states are explicit through `StatePanel`. -- Selecting a zone expands its details. The page also renders the daily Emotional Zone check-in (`ZoneCheckInSection`: reminder banner + `ZoneCheckInCard`) above the content for eligible campus-staff roles — see [`zone-checkin-integration.md`](zone-checkin-integration.md). +- Selecting a zone expands its details. The page also renders the daily Emotional Zone check-in (`ZoneCheckInSection`: reminder banner + `ZoneCheckInCard`) above the content for users with explicit `ZONE_CHECKIN` — see [`zone-checkin-integration.md`](zone-checkin-integration.md). ## Data Ownership Rules diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index c5d47ef..5188e28 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { useEffect } from 'react'; import { QueryClient, QueryClientProvider, @@ -12,11 +13,14 @@ import { Toaster as Sonner } from '@/components/ui/sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; import { ThemeProvider } from '@/components/theme-provider'; import { AuthProvider } from '@/contexts/AuthContext'; +import { ScopeProvider } from '@/contexts/ScopeProvider'; import { APP_DEFAULT_THEME } from '@/shared/constants/theme'; import { FORBIDDEN_ERROR_MESSAGE, isForbiddenError, } from '@/shared/errors/errorMessages'; +import { AUTH_EXPIRED_EVENT } from '@/shared/constants/auth'; +import { ApiError, AuthExpiredError } from '@/shared/api/httpClient'; // Single handler so a backend 403 (permission denied) degrades to a toast // rather than a crash or silent failure, wherever it occurs. @@ -26,7 +30,24 @@ function notifyOnForbidden(error: unknown): void { } } +function shouldRetryQuery(failureCount: number, error: unknown): boolean { + if (error instanceof AuthExpiredError) { + return false; + } + + if (error instanceof ApiError && error.status === 401) { + return false; + } + + return failureCount < 3; +} + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetryQuery, + }, + }, queryCache: new QueryCache({ onError: notifyOnForbidden }), mutationCache: new MutationCache({ onError: notifyOnForbidden }), }); @@ -36,6 +57,15 @@ interface AppProvidersProps { } export function AppProviders({ children }: AppProvidersProps) { + useEffect(() => { + const clearQueries = () => queryClient.clear(); + window.addEventListener(AUTH_EXPIRED_EVENT, clearQueries); + + return () => { + window.removeEventListener(AUTH_EXPIRED_EVENT, clearQueries); + }; + }, []); + return ( @@ -43,7 +73,9 @@ export function AppProviders({ children }: AppProvidersProps) { - {children} + + {children} + diff --git a/frontend/src/app/AuthGuard.tsx b/frontend/src/app/AuthGuard.tsx index 0f7e627..15f4194 100644 --- a/frontend/src/app/AuthGuard.tsx +++ b/frontend/src/app/AuthGuard.tsx @@ -1,17 +1,23 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '@/contexts/useAuth'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +import { AppShellSkeleton } from '@/components/ui/app-shell-skeleton'; /** * Gate for the authenticated app shell (Workstream 3 §3.6). Once the session has * resolved, an unauthenticated visitor is redirected to `/login`; only the * public routes (`/login`, the error page) are reachable without a session. - * While the session is still loading, the shell (`AppLayout`) renders its own - * loader, so this guard renders the outlet and only redirects after resolution. + * While the session is still loading, keep the private shell unmounted. That + * prevents module-level queries from firing before `/auth/me` confirms a + * session. */ export function AuthGuard() { const { isAuthenticated, loading } = useAuth(); + if (loading) { + return ; + } + if (!loading && !isAuthenticated) { return ; } diff --git a/frontend/src/app/IndexRedirect.tsx b/frontend/src/app/IndexRedirect.tsx index b78d4ee..5c1e614 100644 --- a/frontend/src/app/IndexRedirect.tsx +++ b/frontend/src/app/IndexRedirect.tsx @@ -1,15 +1,21 @@ import { Navigate } from 'react-router-dom'; import { useShellOutletContext } from '@/app/shellOutletContext'; -import { getDefaultRoutePathForRole } from '@/shared/constants/moduleRoutes'; +import { getScopedModules } from '@/business/app-shell/selectors'; +import { useScopeContext } from '@/contexts/scope-context'; +import { MODULES } from '@/shared/constants/appData'; +import { DEFAULT_MODULE_ROUTE_PATH } from '@/shared/constants/moduleRoutes'; /** - * Sends the index route (`/`) to the first module the current role can access, - * so every role lands on a page it is allowed to see. + * Sends the index route (`/`) to the first module the current user can access. */ export function IndexRedirect() { const shell = useShellOutletContext(); + const { tier, selectedTenant } = useScopeContext(); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const scopedModules = getScopedModules(MODULES, shell.user, effectiveTier, selectedTenant !== null); + const targetPath = scopedModules[0]?.routePath ?? DEFAULT_MODULE_ROUTE_PATH; return ( - + ); } diff --git a/frontend/src/app/ModuleRouteGuard.tsx b/frontend/src/app/ModuleRouteGuard.tsx index 804a324..47605cc 100644 --- a/frontend/src/app/ModuleRouteGuard.tsx +++ b/frontend/src/app/ModuleRouteGuard.tsx @@ -2,8 +2,10 @@ import type { ReactNode } from 'react'; import { Suspense } from 'react'; import { useLocation } from 'react-router-dom'; import { useShellOutletContext } from '@/app/shellOutletContext'; +import { canAccessScopedModuleRoute } from '@/business/app-shell/selectors'; import { PageSkeleton } from '@/components/ui/page-skeleton'; -import { canUserRoleAccessModuleRoute } from '@/shared/constants/moduleRoutes'; +import { useScopeContext } from '@/contexts/scope-context'; +import { MODULES } from '@/shared/constants/appData'; import NotFound from '@/pages/NotFound'; interface ModuleRouteGuardProps { @@ -13,9 +15,12 @@ interface ModuleRouteGuardProps { export function ModuleRouteGuard({ children }: ModuleRouteGuardProps) { const location = useLocation(); const shell = useShellOutletContext(); + const { tier, selectedTenant } = useScopeContext(); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const isDrilled = selectedTenant !== null; // Forbidden direct-URL access lands on the 404 page (not a silent redirect). - if (!canUserRoleAccessModuleRoute(location.pathname, shell.userRole)) { + if (!canAccessScopedModuleRoute(MODULES, location.pathname, shell.user, effectiveTier, isDrilled)) { return ; } diff --git a/frontend/src/app/appRoutes.test.ts b/frontend/src/app/appRoutes.test.ts index 9bf5413..4ca3849 100644 --- a/frontend/src/app/appRoutes.test.ts +++ b/frontend/src/app/appRoutes.test.ts @@ -22,7 +22,12 @@ describe('app routes', () => { ?.map((route) => route.path) .filter((path): path is string => typeof path === 'string'); - expect(childPaths).toEqual(MODULES.map((module) => module.routePath.slice(1))); + // Module routes (in MODULES order) followed by the non-module shell routes + // (the self-service profile page). + expect(childPaths).toEqual([ + ...MODULES.map((module) => module.routePath.slice(1)), + APP_ROUTE_PATHS.profile.slice(1), + ]); }); it('redirects the shell index route to the dashboard route', () => { diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx index db939d0..ccb008d 100644 --- a/frontend/src/app/appRoutes.tsx +++ b/frontend/src/app/appRoutes.tsx @@ -1,8 +1,13 @@ import type { ReactNode } from 'react'; +import { Suspense } from 'react'; import type { RouteObject } from 'react-router-dom'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +import { MODULES } from '@/shared/constants/appData'; +import type { ModuleId } from '@/shared/types/app'; import { + AcknowledgmentsPage, CampusAttendancePage, + CampusAttendanceDetailsPage, ClassroomSupportPage, ClassroomTimerPage, CommunityPartnershipsPage, @@ -13,10 +18,15 @@ import { FramePage, HandbookPoliciesPage, InternalAlertsPage, - ParentCommunicationPage, + MessagesPage, + MyClassPage, + OrganizationManagementPage, + PlatformDashboardPage, + ProfilePage, QbsSafetyPage, SafetyProtocolsPage, SignLanguagePage, + UserAdminPage, VocationalOpportunitiesPage, WalkthroughPage, ZonesOfRegulationPage, @@ -25,12 +35,64 @@ import { ModuleRouteGuard } from '@/app/ModuleRouteGuard'; import { AuthGuard } from '@/app/AuthGuard'; import { IndexRedirect } from '@/app/IndexRedirect'; import AppLayout from '@/components/AppLayout'; +import { PageSkeleton } from '@/components/ui/page-skeleton'; import Login from '@/pages/Login'; import NotFound from '@/pages/NotFound'; -function moduleRoute(element: ReactNode): ReactNode { - return {element}; -} +/** Element rendered for each product module route (keyed by module id). */ +const MODULE_ELEMENTS: Record = { + 'platform-dashboard': , + 'organization-management': , + 'user-admin': , + class: , + dashboard: , + frame: , + classroom: , + timer: , + qbs: , + ei: , + zones: , + signs: , + attendance: , + 'parent-comm': , + 'internal-comm': , + safety: , + handbook: , + acknowledgments: , + community: , + vocational: , + esa: , + walkthrough: , + director: , +}; + +// One shell child route per product module, in MODULES order, each behind the +// module permission/scope guard. +const moduleRoutes: RouteObject[] = MODULES.map((module) => ({ + path: module.routePath.slice(1), + element: {MODULE_ELEMENTS[module.id]}, +})); + +// Non-module shell routes (available to every authenticated user, not gated by +// the module permission/scope guard). +const extraShellRoutes: RouteObject[] = [ + { + path: APP_ROUTE_PATHS.attendanceDetails.slice(1), + element: ( + + + + ), + }, + { + path: APP_ROUTE_PATHS.profile.slice(1), + element: ( + }> + + + ), + }, +]; export const appRoutes: RouteObject[] = [ { @@ -40,82 +102,9 @@ export const appRoutes: RouteObject[] = [ path: APP_ROUTE_PATHS.home, element: , children: [ - { - index: true, - element: , - }, - { - path: APP_ROUTE_PATHS.dashboard.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.frame.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.classroom.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.timer.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.qbs.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.ei.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.zones.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.signs.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.attendance.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.parentComm.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.internalComm.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.safety.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.handbook.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.community.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.vocational.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.esa.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.walkthrough.slice(1), - element: moduleRoute(), - }, - { - path: APP_ROUTE_PATHS.director.slice(1), - element: moduleRoute(), - }, + { index: true, element: }, + ...extraShellRoutes, + ...moduleRoutes, ], }, ], diff --git a/frontend/src/app/lazyModulePages.ts b/frontend/src/app/lazyModulePages.ts index e2e50b6..e132197 100644 --- a/frontend/src/app/lazyModulePages.ts +++ b/frontend/src/app/lazyModulePages.ts @@ -1,5 +1,6 @@ import { lazy } from 'react'; +export const AcknowledgmentsPage = lazy(() => import('@/pages/modules/AcknowledgmentsPage')); export const DashboardPage = lazy(() => import('@/pages/modules/DashboardPage')); export const FramePage = lazy(() => import('@/pages/modules/FramePage')); export const ClassroomSupportPage = lazy(() => import('@/pages/modules/ClassroomSupportPage')); @@ -9,7 +10,8 @@ export const EmotionalIntelligencePage = lazy(() => import('@/pages/modules/Emot export const ZonesOfRegulationPage = lazy(() => import('@/pages/modules/ZonesOfRegulationPage')); export const SignLanguagePage = lazy(() => import('@/pages/modules/SignLanguagePage')); export const CampusAttendancePage = lazy(() => import('@/pages/modules/CampusAttendancePage')); -export const ParentCommunicationPage = lazy(() => import('@/pages/modules/ParentCommunicationPage')); +export const CampusAttendanceDetailsPage = lazy(() => import('@/pages/modules/CampusAttendanceDetailsPage')); +export const MessagesPage = lazy(() => import('@/pages/modules/MessagesPage')); export const InternalAlertsPage = lazy(() => import('@/pages/modules/InternalAlertsPage')); export const SafetyProtocolsPage = lazy(() => import('@/pages/modules/SafetyProtocolsPage')); export const HandbookPoliciesPage = lazy(() => import('@/pages/modules/HandbookPoliciesPage')); @@ -18,3 +20,8 @@ export const VocationalOpportunitiesPage = lazy(() => import('@/pages/modules/Vo export const EsaFundingPage = lazy(() => import('@/pages/modules/EsaFundingPage')); export const WalkthroughPage = lazy(() => import('@/pages/modules/WalkthroughPage')); export const DirectorDashboardPage = lazy(() => import('@/pages/modules/DirectorDashboardPage')); +export const PlatformDashboardPage = lazy(() => import('@/pages/modules/PlatformDashboardPage')); +export const OrganizationManagementPage = lazy(() => import('@/pages/modules/CreateTenantPage')); +export const UserAdminPage = lazy(() => import('@/pages/modules/UserAdminPage')); +export const MyClassPage = lazy(() => import('@/pages/modules/MyClassPage')); +export const ProfilePage = lazy(() => import('@/pages/ProfilePage')); diff --git a/frontend/src/business/app-shell/hooks.test.tsx b/frontend/src/business/app-shell/hooks.test.tsx new file mode 100644 index 0000000..42c1aa6 --- /dev/null +++ b/frontend/src/business/app-shell/hooks.test.tsx @@ -0,0 +1,134 @@ +import type { ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAppShell } from '@/business/app-shell/hooks'; +import type { ScopeContextValue } from '@/contexts/scope-context'; +import type { CurrentUser } from '@/shared/types/auth'; + +const mockNavigate = vi.fn(); +const mockScopeContext = vi.fn<() => ScopeContextValue>(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock('@/shared/app/scope-context', () => ({ + useScopeContext: () => mockScopeContext(), +})); + +vi.mock('@/business/campuses/hooks', () => ({ + useCampusCatalog: () => ({ + campuses: [], + isLoading: false, + error: null, + }), +})); + +const globalUser: CurrentUser = { + id: 'admin-1', + email: 'admin@example.com', + permissions: ['READ_PLATFORM_DASHBOARD', 'READ_DASHBOARD'], + app_role: { name: 'super_admin', globalAccess: true }, +}; + +function createScopeContext(overrides: Partial = {}): ScopeContextValue { + return { + tier: 'global', + ownTenant: null, + selectedTenant: null, + effectiveTenant: null, + canDrill: true, + drillInto: vi.fn(), + setScopeTenant: vi.fn(), + resetScope: vi.fn(), + ...overrides, + }; +} + +function createWrapper(initialEntry: string | { pathname: string; state?: unknown }) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useAppShell', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects into the drilled tenant route instead of resetting when scope changes on /platform-dashboard', async () => { + const resetScope = vi.fn(); + const drilledContext = createScopeContext({ + selectedTenant: { id: 'org-1', level: 'organization', name: 'Demo Academy', logo: null }, + effectiveTenant: { id: 'org-1', level: 'organization', name: 'Demo Academy', logo: null }, + resetScope, + }); + const ownContext = createScopeContext(); + mockScopeContext + .mockReturnValueOnce(ownContext) + .mockReturnValue(drilledContext); + + const { rerender } = renderHook( + () => useAppShell({ user: globalUser, profile: null, isMobile: false }), + { wrapper: createWrapper('/platform-dashboard') }, + ); + + rerender(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { + replace: true, + state: { + __scope: { + id: 'org-1', + level: 'organization', + logo: null, + name: 'Demo Academy', + }, + }, + }); + }); + expect(resetScope).not.toHaveBeenCalled(); + }); + + it('keeps drilled tenant state on nested attendance detail routes', async () => { + const selectedTenant = { id: 'org-1', level: 'organization', name: 'Demo Academy', logo: null } as const; + const resetScope = vi.fn(); + const setScopeTenant = vi.fn(); + mockScopeContext.mockReturnValue(createScopeContext({ + selectedTenant, + effectiveTenant: selectedTenant, + resetScope, + setScopeTenant, + })); + + renderHook( + () => useAppShell({ + user: { + ...globalUser, + permissions: [...(globalUser.permissions ?? []), 'READ_ATTENDANCE'], + }, + profile: null, + isMobile: false, + }), + { + wrapper: createWrapper({ + pathname: '/attendance/details/school/school-1', + state: { __scope: selectedTenant }, + }), + }, + ); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + expect(resetScope).not.toHaveBeenCalled(); + expect(setScopeTenant).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts index d6f996c..552d5af 100644 --- a/frontend/src/business/app-shell/hooks.ts +++ b/frontend/src/business/app-shell/hooks.ts @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { MODULES } from '@/shared/constants/appData'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import { DEFAULT_CAMPUS_LABEL, findCampusByNameOrCode, @@ -13,18 +14,33 @@ import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import type { ModuleId, UserRole } from '@/shared/types/app'; import { getAccessibleModuleId, - getAccessibleModules, + getScopedModules, + getScopedModuleRouteRedirectPath, getSidebarCampusInitial, getSidebarRoleLabel, shouldShowMobileSidebarOverlay, + withRoleAwareModuleNames, } from '@/business/app-shell/selectors'; import { useCampusCatalog } from '@/business/campuses/hooks'; +import { useScopeContext } from '@/shared/app/scope-context'; import type { AppShellState, SidebarPage, SidebarProps, UseAppShellOptions, } from '@/business/app-shell/types'; +import type { ActiveTenant } from '@/shared/types/scope'; + +interface ScopeRouteState { + readonly __scope?: ActiveTenant | null; + readonly [key: string]: unknown; +} + +function sameScopeTenant(a: ActiveTenant | null, b: ActiveTenant | null): boolean { + if (a === b) return true; + if (!a || !b) return a === b; + return a.id === b.id && a.level === b.level; +} function getUserRole(options: UseAppShellOptions): UserRole { return options.profile?.role ?? DEFAULT_PRODUCT_ROLE; @@ -41,6 +57,7 @@ function getUserCampus(options: UseAppShellOptions): string { export function useAppShell(options: UseAppShellOptions): AppShellState { const location = useLocation(); const navigate = useNavigate(); + const { tier, selectedTenant, resetScope, setScopeTenant } = useScopeContext(); const campusCatalog = useCampusCatalog(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -49,14 +66,109 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { const userRole = getUserRole(options); const userName = getUserName(options); const userCampus = getUserCampus(options); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const isDrilled = selectedTenant !== null; + const effectiveScopeKey = selectedTenant + ? `${selectedTenant.level}:${selectedTenant.id}` + : `own:${tier}`; + const previousScopeKey = useRef(effectiveScopeKey); + const previousPathname = useRef(location.pathname); + const scopedModules = getScopedModules(MODULES, options.user, effectiveTier, isDrilled); const currentRouteModule = getModuleIdByRoutePath(location.pathname); - const currentModule = getAccessibleModuleId(MODULES, currentRouteModule, userRole); - const activeModule = getAccessibleModuleId(MODULES, currentModule, userRole); + const currentModule = getAccessibleModuleId(scopedModules, currentRouteModule, options.user); + const activeModule = getAccessibleModuleId(scopedModules, currentModule, options.user); const campusInfo = findCampusByNameOrCode(campusCatalog.campuses, userCampus); const mobileOverlayVisible = shouldShowMobileSidebarOverlay(options.isMobile, mobileSidebarOpen); + const routeState = (location.state as ScopeRouteState | null) ?? null; + const routeScopeTenant = routeState?.__scope ?? null; + + useEffect(() => { + const scopeChanged = previousScopeKey.current !== effectiveScopeKey; + const pathnameChanged = previousPathname.current !== location.pathname; + previousScopeKey.current = effectiveScopeKey; + previousPathname.current = location.pathname; + + if (pathnameChanged && !scopeChanged && !sameScopeTenant(routeScopeTenant, selectedTenant)) { + if (routeScopeTenant) { + setScopeTenant(routeScopeTenant); + } else { + resetScope(); + } + return; + } + + if (location.pathname === APP_ROUTE_PATHS.platformDashboard && tier === 'global' && selectedTenant !== null) { + if (scopeChanged) { + const drilledRedirectPath = getScopedModuleRouteRedirectPath( + MODULES, + location.pathname, + options.user, + effectiveTier, + isDrilled, + ); + if (drilledRedirectPath && drilledRedirectPath !== location.pathname) { + navigate(drilledRedirectPath, { + replace: true, + state: { ...routeState, __scope: selectedTenant }, + }); + } + return; + } + } + + const redirectPath = getScopedModuleRouteRedirectPath( + MODULES, + location.pathname, + options.user, + effectiveTier, + isDrilled, + ); + if (redirectPath && redirectPath !== location.pathname) { + navigate(redirectPath, { + replace: true, + state: { ...routeState, __scope: selectedTenant }, + }); + return; + } + + if (!sameScopeTenant(routeScopeTenant, selectedTenant)) { + navigate( + { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }, + { + replace: true, + state: { ...routeState, __scope: selectedTenant }, + }, + ); + } + }, [ + effectiveScopeKey, + location.hash, + effectiveTier, + isDrilled, + location.pathname, + location.search, + navigate, + options.user, + resetScope, + routeScopeTenant, + routeState, + selectedTenant, + setScopeTenant, + tier, + ]); const setCurrentModule = (id: ModuleId) => { - navigate(getModuleRoutePath(id)); + const targetModule = scopedModules.find((module) => module.id === id); + navigate( + targetModule?.routePath ?? scopedModules[0]?.routePath ?? getModuleRoutePath(id), + { + state: { ...routeState, __scope: selectedTenant }, + }, + ); if (options.isMobile) { setMobileSidebarOpen(false); @@ -75,6 +187,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { const sidebarProps: SidebarProps = { currentModule: activeModule, setCurrentModule, + user: options.user, userRole, collapsed: options.isMobile ? false : sidebarCollapsed, setCollapsed: setSidebarCollapsed, @@ -82,6 +195,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { }; const topBarProps = { + user: options.user, userRole, userName, campusInfo, @@ -90,6 +204,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { }; const shellOutletContext = { + user: options.user, userRole, userName, userCampus, @@ -101,6 +216,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { const footerProps = { userName, userRole, + modules: scopedModules, setCurrentModule, }; @@ -129,17 +245,26 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { export function useSidebarPage({ currentModule, setCurrentModule, + user, userRole, collapsed, setCollapsed, campusInfo, }: SidebarProps): SidebarPage { + const { tier, selectedTenant } = useScopeContext(); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const isDrilled = selectedTenant !== null; + return { currentModule, + user, userRole, collapsed, campusInfo, - modules: getAccessibleModules(MODULES, userRole), + modules: withRoleAwareModuleNames( + getScopedModules(MODULES, user, effectiveTier, isDrilled), + userRole, + ), roleLabel: getSidebarRoleLabel(userRole), campusInitial: getSidebarCampusInitial(campusInfo), setCurrentModule, diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index a0d2b68..b786dfe 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -1,20 +1,42 @@ import { describe, expect, it } from 'vitest'; import { canAccessModule, + canAccessScopedModuleRoute, getAccessibleModuleId, getAccessibleModules, + getScopedModules, + getScopedModuleRouteRedirectPath, getSidebarCampusInitial, getSidebarRoleLabel, shouldShowMobileSidebarOverlay, } from '@/business/app-shell/selectors'; import type { Module } from '@/shared/types/app'; +import type { CurrentUser } from '@/shared/types/auth'; + +function user(permissions: readonly string[]): CurrentUser { + return { + id: 'user-1', + email: 'user@example.com', + app_role: { globalAccess: false }, + permissions, + }; +} + +function globalUser(): CurrentUser { + return { + id: 'admin-1', + email: 'admin@example.com', + app_role: { name: 'super_admin', globalAccess: true }, + permissions: [], + }; +} const modules: readonly Module[] = [ { id: 'dashboard', name: 'Dashboard', icon: 'home', - roles: ['teacher', 'support_staff', 'office_manager', 'director', 'superintendent'], + permissions: ['READ_DASHBOARD'], color: 'text-blue-400', routePath: '/dashboard', }, @@ -22,27 +44,124 @@ const modules: readonly Module[] = [ id: 'director', name: 'Director', icon: 'users', - roles: ['director', 'superintendent'], + permissions: ['READ_DIRECTOR_DASHBOARD'], color: 'text-emerald-400', routePath: '/director-dashboard', }, ]; +const scopedModules: readonly Module[] = [ + { id: 'platform-dashboard', name: 'Platform', icon: 'chart', permissions: ['READ_PLATFORM_DASHBOARD'], color: '', routePath: '/platform-dashboard' }, + { id: 'dashboard', name: 'Dashboard', icon: 'home', permissions: ['READ_DASHBOARD'], color: '', routePath: '/dashboard' }, + { id: 'classroom', name: 'Classroom Support', icon: 'book', permissions: ['READ_CLASSROOM'], color: '', routePath: '/classroom-support' }, + { id: 'timer', name: 'Timer', icon: 'timer', permissions: ['READ_TIMER'], color: '', routePath: '/timer' }, + { 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: 'director', name: 'Director', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: '', routePath: '/director-dashboard' }, +]; + describe('app-shell selectors', () => { - it('checks module access from module role metadata', () => { - expect(canAccessModule(modules, 'dashboard', 'teacher')).toBe(true); - expect(canAccessModule(modules, 'director', 'teacher')).toBe(false); - expect(canAccessModule(modules, 'director', 'superintendent')).toBe(true); + it('checks module access from effective permissions', () => { + expect(canAccessModule(modules, 'dashboard', user(['READ_DASHBOARD']))).toBe(true); + expect(canAccessModule(modules, 'director', user(['READ_DASHBOARD']))).toBe(false); + expect(canAccessModule(modules, 'director', user(['READ_DIRECTOR_DASHBOARD']))).toBe(true); }); it('falls back to dashboard when the requested module is unavailable', () => { - expect(getAccessibleModuleId(modules, 'director', 'teacher')).toBe('dashboard'); - expect(getAccessibleModuleId(modules, 'director', 'director')).toBe('director'); + expect(getAccessibleModuleId(modules, 'director', user(['READ_DASHBOARD']))).toBe('dashboard'); + expect(getAccessibleModuleId(modules, 'director', user(['READ_DIRECTOR_DASHBOARD']))).toBe('director'); }); - it('returns modules available to the selected role', () => { - expect(getAccessibleModules(modules, 'teacher').map((module) => module.id)).toEqual(['dashboard']); - expect(getAccessibleModules(modules, 'director').map((module) => module.id)).toEqual(['dashboard', 'director']); + it("returns modules available to the user's permissions", () => { + expect(getAccessibleModules(modules, user(['READ_DASHBOARD'])).map((module) => module.id)).toEqual(['dashboard']); + expect(getAccessibleModules(modules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD'])).map((module) => module.id)).toEqual(['dashboard', 'director']); + }); + + it('scopes modules by effective tier on top of permission access', () => { + expect( + getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_CLASSROOM', 'READ_TIMER']), 'class', false).map((m) => m.id), + ).toEqual(['dashboard', 'classroom', 'timer']); + + expect( + getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_CLASSROOM', 'READ_TIMER', 'READ_ZONES', 'READ_DIRECTOR_DASHBOARD']), 'campus', false).map((m) => m.id), + ).toEqual(['dashboard', 'classroom', 'zones', 'director']); + }); + + it('makes Classroom Support available from organization through class scopes', () => { + const classroomUser = user(['READ_CLASSROOM']); + + expect(getScopedModules(scopedModules, classroomUser, 'organization', false).map((m) => m.id)).toEqual(['classroom']); + expect(getScopedModules(scopedModules, classroomUser, 'school', false).map((m) => m.id)).toEqual(['classroom']); + expect(getScopedModules(scopedModules, classroomUser, 'campus', false).map((m) => m.id)).toEqual(['classroom']); + expect(getScopedModules(scopedModules, classroomUser, 'class', false).map((m) => m.id)).toEqual(['classroom']); + }); + + it('makes Behavior Management available at organization, campus, and class scopes', () => { + const behaviorUser = user(['READ_QBS']); + + expect(getScopedModules(scopedModules, behaviorUser, 'organization', false).map((m) => m.id)).toEqual(['qbs']); + expect(getScopedModules(scopedModules, behaviorUser, 'school', false).map((m) => m.id)).toEqual([]); + expect(getScopedModules(scopedModules, behaviorUser, 'campus', false).map((m) => m.id)).toEqual(['qbs']); + expect(getScopedModules(scopedModules, behaviorUser, 'class', false).map((m) => m.id)).toEqual(['qbs']); + }); + + it('never shows the Director Dashboard via drill-down', () => { + expect( + getScopedModules(scopedModules, user(['READ_DASHBOARD', 'READ_DIRECTOR_DASHBOARD']), 'campus', true).map((m) => m.id), + ).toEqual(['dashboard']); + }); + + it('applies scope tiers to direct module route access', () => { + expect(canAccessScopedModuleRoute(scopedModules, '/platform-dashboard', globalUser(), 'global', false)).toBe(true); + expect(canAccessScopedModuleRoute(scopedModules, '/dashboard', globalUser(), 'global', false)).toBe(false); + expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', globalUser(), 'global', false)).toBe(false); + expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'organization', false)).toBe(true); + expect(canAccessScopedModuleRoute(scopedModules, '/classroom-support', user(['READ_CLASSROOM']), 'school', false)).toBe(true); + expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'organization', false)).toBe(true); + expect(canAccessScopedModuleRoute(scopedModules, '/qbs-safety', user(['READ_QBS']), 'school', false)).toBe(false); + expect(canAccessScopedModuleRoute(scopedModules, '/zones-of-regulation', user(['READ_ZONES']), 'class', false)).toBe(true); + }); + + it('redirects invalid module routes after effective scope changes', () => { + expect( + getScopedModuleRouteRedirectPath( + scopedModules, + '/platform-dashboard', + globalUser(), + 'organization', + true, + ), + ).toBe('/dashboard'); + + expect( + getScopedModuleRouteRedirectPath( + scopedModules, + '/dashboard', + globalUser(), + 'global', + false, + ), + ).toBe('/platform-dashboard'); + + expect( + getScopedModuleRouteRedirectPath( + scopedModules, + '/dashboard', + user(['READ_DASHBOARD']), + 'organization', + false, + ), + ).toBeNull(); + + expect( + getScopedModuleRouteRedirectPath( + scopedModules, + '/profile', + user(['READ_DASHBOARD']), + 'organization', + false, + ), + ).toBeNull(); }); it('formats sidebar role labels through shared auth role labels', () => { diff --git a/frontend/src/business/app-shell/selectors.ts b/frontend/src/business/app-shell/selectors.ts index b4d95fa..0bcec8b 100644 --- a/frontend/src/business/app-shell/selectors.ts +++ b/frontend/src/business/app-shell/selectors.ts @@ -1,27 +1,150 @@ import { getAuthRoleLabel } from '@/business/auth/selectors'; +import { hasAnyPermission } from '@/business/auth/permissions'; +import { getModuleByRoutePath } from '@/shared/constants/moduleRoutes'; +import type { ScopeTier } from '@/business/scope/selectors'; import type { CampusInfo, Module, ModuleId, UserRole } from '@/shared/types/app'; +import type { CurrentUser } from '@/shared/types/auth'; export function getAccessibleModules( modules: readonly Module[], - userRole: UserRole, + user: CurrentUser | null | undefined, ): readonly Module[] { - return modules.filter((module) => module.roles.includes(userRole)); + return modules.filter((module) => hasAnyPermission(user, module.permissions)); +} + +const ALL_STAFF_TIERS: readonly ScopeTier[] = [ + 'organization', + 'school', + 'campus', + 'class', +]; + +/** + * The scope tiers at which each module appears in the sidebar. Layered on top of + * permission access: a module shows only if the user has one of its required + * permissions AND the + * effective scope tier (own, or a drilled-into tenant) is listed here. Modules + * not listed default to all staff tiers. + */ +const MODULE_SCOPE_TIERS: Partial> = { + 'platform-dashboard': ['global'], + // Organization/location management: visible at manager scopes. + 'organization-management': ['global', 'organization', 'school', 'campus'], + 'user-admin': ['global', 'organization', 'school', 'campus'], + class: ['class'], + classroom: ['organization', 'school', 'campus', 'class'], + timer: ['class'], + qbs: ['organization', 'campus', 'class'], + // Leadership dashboard: each leader sees it at their own tier (owner/ + // superintendent → organization, principal/registrar → school, director → + // campus). Never shown via drill-down (see getScopedModules). + director: ['organization', 'school', 'campus'], + 'parent-comm': ['school', 'campus', 'class', 'external'], + esa: ['school', 'campus', 'class', 'external'], + walkthrough: ['organization', 'school', 'campus'], + 'internal-comm': ['global', ...ALL_STAFF_TIERS], + community: [...ALL_STAFF_TIERS, 'external'], + vocational: [...ALL_STAFF_TIERS, 'external'], +}; + +/** + * Sidebar modules for the user, scoped to the effective tier (own tier, or the + * tenant they have drilled into). The Director Dashboard is the campus + * director's own page and is never shown via drill-down. + */ +export function getScopedModules( + modules: readonly Module[], + user: CurrentUser | null | undefined, + effectiveTier: ScopeTier, + isDrilled: boolean, +): readonly Module[] { + return getAccessibleModules(modules, user).filter((module) => { + const tiers = MODULE_SCOPE_TIERS[module.id] ?? ALL_STAFF_TIERS; + if (!tiers.includes(effectiveTier)) { + return false; + } + if (module.id === 'director' && isDrilled) { + return false; + } + return true; + }); +} + +export function canAccessScopedModuleRoute( + modules: readonly Module[], + pathname: string, + user: CurrentUser | null | undefined, + effectiveTier: ScopeTier, + isDrilled: boolean, +): boolean { + const module = getModuleByRoutePath(pathname); + if (!module) return true; + return getScopedModules(modules, user, effectiveTier, isDrilled) + .some((allowed) => allowed.id === module.id); +} + +export function getScopedModuleRouteRedirectPath( + modules: readonly Module[], + pathname: string, + user: CurrentUser | null | undefined, + effectiveTier: ScopeTier, + isDrilled: boolean, +): string | null { + const currentModule = getModuleByRoutePath(pathname); + if (!currentModule) return null; + + const scopedModules = getScopedModules(modules, user, effectiveTier, isDrilled); + if (scopedModules.some((module) => module.id === currentModule.id)) { + return null; + } + + return scopedModules[0]?.routePath ?? null; +} + +/** Role-specific name for the shared leadership dashboard (`director` module). */ +const LEADERSHIP_DASHBOARD_LABELS: Partial> = { + owner: 'Owner Dashboard', + superintendent: 'Superintendent Dashboard', + principal: 'Principal Dashboard', + registrar: 'Registrar Dashboard', + director: 'Director Dashboard', +}; + +export function getLeadershipDashboardName(role: UserRole): string { + return LEADERSHIP_DASHBOARD_LABELS[role] ?? 'Leadership Dashboard'; +} + +/** + * Rewrites the shared leadership dashboard's sidebar label so each role sees its + * own name (Owner Dashboard, Principal Dashboard, …) rather than the generic + * "Director Dashboard". + */ +export function withRoleAwareModuleNames( + modules: readonly Module[], + role: UserRole, +): readonly Module[] { + return modules.map((module) => + module.id === 'director' + ? { ...module, name: getLeadershipDashboardName(role) } + : module, + ); } export function canAccessModule( modules: readonly Module[], moduleId: ModuleId, - userRole: UserRole, + user: CurrentUser | null | undefined, ): boolean { - return Boolean(modules.find((module) => module.id === moduleId)?.roles.includes(userRole)); + const module = modules.find((item) => item.id === moduleId); + return module ? hasAnyPermission(user, module.permissions) : false; } export function getAccessibleModuleId( modules: readonly Module[], moduleId: ModuleId, - userRole: UserRole, + user: CurrentUser | null | undefined, ): ModuleId { - return canAccessModule(modules, moduleId, userRole) ? moduleId : 'dashboard'; + return canAccessModule(modules, moduleId, user) ? moduleId : 'dashboard'; } export function getSidebarRoleLabel(role: UserRole): string { diff --git a/frontend/src/business/app-shell/types.ts b/frontend/src/business/app-shell/types.ts index a0827b9..b116606 100644 --- a/frontend/src/business/app-shell/types.ts +++ b/frontend/src/business/app-shell/types.ts @@ -3,13 +3,15 @@ import type { CampusInfo, Module, ModuleId, - StaffProfile, + UserProfile, UserRole, } from '@/shared/types/app'; import type { TopBarProps } from '@/business/top-bar/types'; +import type { CurrentUser } from '@/shared/types/auth'; export interface UseAppShellOptions { - readonly profile: StaffProfile | null; + readonly user: CurrentUser | null; + readonly profile: UserProfile | null; readonly isMobile: boolean; } @@ -35,6 +37,7 @@ export interface AppShellState { } export interface ShellOutletContext { + readonly user: CurrentUser | null; readonly userRole: UserRole; readonly userName: string; readonly userCampus: string; @@ -46,6 +49,7 @@ export interface ShellOutletContext { export interface SidebarProps { readonly currentModule: ModuleId; readonly setCurrentModule: (id: ModuleId) => void; + readonly user: CurrentUser | null; readonly userRole: UserRole; readonly collapsed: boolean; readonly setCollapsed: (value: boolean) => void; @@ -54,6 +58,7 @@ export interface SidebarProps { export interface SidebarPage { readonly currentModule: ModuleId; + readonly user: CurrentUser | null; readonly userRole: UserRole; readonly collapsed: boolean; readonly campusInfo?: CampusInfo; @@ -67,5 +72,6 @@ export interface SidebarPage { export interface AppFooterProps { readonly userName: string; readonly userRole: UserRole; + readonly modules: readonly Module[]; readonly setCurrentModule: (id: ModuleId) => void; } diff --git a/frontend/src/business/audio-files/selectors.test.ts b/frontend/src/business/audio-files/selectors.test.ts deleted file mode 100644 index 0561c96..0000000 --- a/frontend/src/business/audio-files/selectors.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { canManageAudioFiles } from '@/business/audio-files/selectors'; - -describe('canManageAudioFiles', () => { - it('mirrors the MANAGE_AUDIO_FILES grant (director/office_manager/teacher only)', () => { - expect(canManageAudioFiles('director')).toBe(true); - expect(canManageAudioFiles('office_manager')).toBe(true); - expect(canManageAudioFiles('teacher')).toBe(true); - expect(canManageAudioFiles('support_staff')).toBe(false); - expect(canManageAudioFiles('owner')).toBe(false); - expect(canManageAudioFiles('superintendent')).toBe(false); - expect(canManageAudioFiles('student')).toBe(false); - }); -}); diff --git a/frontend/src/business/audio-files/selectors.ts b/frontend/src/business/audio-files/selectors.ts deleted file mode 100644 index 573f7eb..0000000 --- a/frontend/src/business/audio-files/selectors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { UserRole } from '@/shared/types/app'; - -/** - * Roles that may add/remove audio-library entries — mirrors the backend - * `MANAGE_AUDIO_FILES` grant (director/office_manager/teacher; support_staff is - * read/play-only). The backend stays the source of truth; this only gates the - * management UI affordances (Generate / delete). - */ -export function canManageAudioFiles(userRole: UserRole): boolean { - return ( - userRole === 'director' || - userRole === 'office_manager' || - userRole === 'teacher' - ); -} diff --git a/frontend/src/business/auth/hooks.ts b/frontend/src/business/auth/hooks.ts index 20899c9..80d4339 100644 --- a/frontend/src/business/auth/hooks.ts +++ b/frontend/src/business/auth/hooks.ts @@ -2,14 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { getCurrentUser, signIn, signOut as signOutRequest } from '@/shared/api/auth'; import { AuthExpiredError } from '@/shared/api/httpClient'; +import { AUTH_EXPIRED_EVENT } from '@/shared/constants/auth'; import { AUTH_MODAL_SIGNIN_CLOSE_DELAY_MS, AUTH_MODAL_SIGNUP_CLOSE_DELAY_MS, } from '@/shared/constants/auth'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import { CurrentUser } from '@/shared/types/auth'; -import { StaffProfile } from '@/shared/types/app'; -import { toStaffProfile } from '@/business/auth/mappers'; +import { UserProfile } from '@/shared/types/app'; +import { toUserProfile } from '@/business/auth/mappers'; import { useCampusCatalog } from '@/business/campuses/hooks'; import { AuthActionResult, AuthSessionState } from '@/business/auth/types'; import { getErrorMessage, getOptionalErrorMessage } from '@/shared/errors/errorMessages'; @@ -40,6 +41,19 @@ export function useAuthSession(): AuthSessionState { navigate(APP_ROUTE_PATHS.login, { replace: true }); }, [navigate]); + const expireSession = useCallback(() => { + clearSession(); + redirectToLogin(); + }, [clearSession, redirectToLogin]); + + useEffect(() => { + window.addEventListener(AUTH_EXPIRED_EVENT, expireSession); + + return () => { + window.removeEventListener(AUTH_EXPIRED_EVENT, expireSession); + }; + }, [expireSession]); + useEffect(() => { let isActive = true; @@ -53,7 +67,7 @@ export function useAuthSession(): AuthSessionState { if (isActive) { clearSession(); if (error instanceof AuthExpiredError) { - redirectToLogin(); + expireSession(); } } }) @@ -66,7 +80,7 @@ export function useAuthSession(): AuthSessionState { return () => { isActive = false; }; - }, [clearSession, redirectToLogin]); + }, [clearSession, expireSession]); const handleSignIn = useCallback(async (email: string, password: string): Promise => { setLoading(true); @@ -78,17 +92,17 @@ export function useAuthSession(): AuthSessionState { } catch (error) { clearSession(); if (error instanceof AuthExpiredError) { - redirectToLogin(); + expireSession(); } return { error: getErrorMessage(error, 'Sign in failed') }; } finally { setLoading(false); } - }, [clearSession, redirectToLogin]); + }, [clearSession, expireSession]); const signUp = useCallback(async (): Promise => ({ error: - 'Account creation is not available until backend product roles, campus assignment, and staff profile creation are implemented.', + 'Account creation is not available until backend product roles and campus assignment are implemented.', }), []); const signOut = useCallback(async (): Promise => { @@ -98,19 +112,29 @@ export function useAuthSession(): AuthSessionState { return { error: null }; } catch (error) { if (error instanceof AuthExpiredError) { - clearSession(); - redirectToLogin(); + expireSession(); return { error: null }; } return { error: getErrorMessage(error, 'Sign out failed') }; } - }, [clearSession, redirectToLogin]); + }, [clearSession, expireSession]); const updateProfile = useCallback(async (): Promise => ({ - error: 'Profile updates are not available until backend staff profile updates are implemented.', + error: 'Profile updates are handled by the profile page.', }), []); - const profile: StaffProfile | null = useMemo(() => (user ? toStaffProfile(user) : null), [user]); + const refreshUser = useCallback(async (): Promise => { + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + } catch (error) { + if (error instanceof AuthExpiredError) { + expireSession(); + } + } + }, [expireSession]); + + const profile: UserProfile | null = useMemo(() => (user ? toUserProfile(user) : null), [user]); return { user, @@ -120,6 +144,7 @@ export function useAuthSession(): AuthSessionState { signUp, signOut, updateProfile, + refreshUser, isAuthenticated: Boolean(user), }; } diff --git a/frontend/src/business/auth/mappers.test.ts b/frontend/src/business/auth/mappers.test.ts index a860e52..b911073 100644 --- a/frontend/src/business/auth/mappers.test.ts +++ b/frontend/src/business/auth/mappers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { getUserDisplayName, - toStaffProfile, + toUserProfile, } from '@/business/auth/mappers'; import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; import type { CurrentUser } from '@/shared/types/auth'; @@ -17,16 +17,7 @@ function createUser(overrides: Partial = {}): CurrentUser { organizationId: 'org-1', campus: { id: 'campus-1', name: 'North Campus', code: 'north' }, campusId: 'campus-1', - staffProfile: { - id: 'staff-1', - employee_number: 'E-1', - job_title: 'Teacher', - staff_type: 'instructional', - status: 'active', - organizationId: 'org-1', - campusId: 'campus-1', - userId: 'user-1', - }, + avatar: '/uploads/avatar.png', permissions: [], ...overrides, }; @@ -38,22 +29,22 @@ describe('auth mappers', () => { expect(getUserDisplayName(createUser({ firstName: null, lastName: null }))).toBe('teacher@example.com'); }); - it('maps current user to staff profile with backend staff id and campus name', () => { - expect(toStaffProfile(createUser())).toEqual({ - id: 'staff-1', + it('maps current user to UI profile with user id and campus name', () => { + expect(toUserProfile(createUser())).toEqual({ + id: 'user-1', full_name: 'Ava Lee', role: 'teacher', campus: 'North Campus', - avatar_url: null, + avatar_url: '/uploads/avatar.png', }); }); it('uses user id and default campus label when optional backend profile data is absent', () => { - expect(toStaffProfile(createUser({ + expect(toUserProfile(createUser({ campus: null, campusId: null, app_role: { name: 'support_staff' }, - staffProfile: null, + avatar: null, }))).toEqual({ id: 'user-1', full_name: 'Ava Lee', diff --git a/frontend/src/business/auth/mappers.ts b/frontend/src/business/auth/mappers.ts index 32c51ca..b38b711 100644 --- a/frontend/src/business/auth/mappers.ts +++ b/frontend/src/business/auth/mappers.ts @@ -1,7 +1,7 @@ import { DEFAULT_PRODUCT_ROLE, isUserRole } from '@/shared/constants/roles'; import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; import { CurrentUser } from '@/shared/types/auth'; -import { StaffProfile, UserRole } from '@/shared/types/app'; +import { UserProfile, UserRole } from '@/shared/types/app'; function getProductRole(user: CurrentUser): UserRole { const name = user.app_role?.name; @@ -14,12 +14,12 @@ export function getUserDisplayName(user: CurrentUser): string { return fullName || user.email; } -export function toStaffProfile(user: CurrentUser): StaffProfile { +export function toUserProfile(user: CurrentUser): UserProfile { return { - id: user.staffProfile?.id || user.id, + id: user.id, full_name: getUserDisplayName(user), role: getProductRole(user), campus: user.campus?.name || user.campus?.code || DEFAULT_CAMPUS_LABEL, - avatar_url: null, + avatar_url: user.avatar ?? null, }; } diff --git a/frontend/src/business/auth/permissions.test.ts b/frontend/src/business/auth/permissions.test.ts index 7d5e164..11c4486 100644 --- a/frontend/src/business/auth/permissions.test.ts +++ b/frontend/src/business/auth/permissions.test.ts @@ -22,7 +22,7 @@ describe('permission selectors', () => { expect(hasPermission(u, 'DELETE_CAMPUSES')).toBe(false); }); - it('treats a global-access role as having every permission', () => { + it('treats the super_admin role as having every permission', () => { const admin = user({ permissions: [], app_role: { name: 'super_admin', globalAccess: true }, @@ -31,6 +31,19 @@ describe('permission selectors', () => { expect(hasAllPermissions(admin, ['CREATE_USERS', 'DELETE_USERS'])).toBe(true); }); + it('does not bypass explicit personal workflow permissions for super_admin', () => { + const admin = user({ + permissions: [], + app_role: { name: 'super_admin', globalAccess: true }, + }); + + expect(hasPermission(admin, 'READ_PARENT_COMM')).toBe(false); + expect(hasPermission(admin, 'ACK_POLICY')).toBe(false); + expect(hasPermission(admin, 'ZONE_CHECKIN')).toBe(false); + expect(hasAnyPermission(admin, ['READ_PARENT_COMM', 'READ_DASHBOARD'])).toBe(true); + expect(hasAllPermissions(admin, ['READ_PARENT_COMM', 'READ_DASHBOARD'])).toBe(false); + }); + it('denies everything for a null user', () => { expect(hasPermission(null, 'READ_CAMPUSES')).toBe(false); expect(hasAnyPermission(null, ['READ_CAMPUSES'])).toBe(false); @@ -50,4 +63,13 @@ describe('permission selectors', () => { }); expect(hasPermission(teacher, 'READ_FRAME')).toBe(false); }); + + it('does not treat system_admin global scope as implicit all-access without permissions', () => { + const systemAdmin = user({ + permissions: [], + app_role: { name: 'system_admin', globalAccess: true }, + }); + expect(hasPermission(systemAdmin, 'DELETE_ORGANIZATIONS')).toBe(false); + expect(hasAnyPermission(systemAdmin, ['DELETE_ORGANIZATIONS', 'UPDATE_USERS'])).toBe(false); + }); }); diff --git a/frontend/src/business/auth/permissions.ts b/frontend/src/business/auth/permissions.ts index a5d4c12..a403ac1 100644 --- a/frontend/src/business/auth/permissions.ts +++ b/frontend/src/business/auth/permissions.ts @@ -1,35 +1,45 @@ import type { CurrentUser } from '@/shared/types/auth'; -import type { PermissionName } from '@/shared/auth/permissions'; +import { + GLOBAL_BYPASS_EXCLUDED_PERMISSIONS, + type PermissionName, +} from '@/shared/auth/permissions'; function permissionsOf(user: CurrentUser | null | undefined): readonly string[] { return user?.permissions ?? []; } -function hasGlobalAccess(user: CurrentUser | null | undefined): boolean { - return user?.app_role?.globalAccess === true; +function hasPermissionBypass(user: CurrentUser | null | undefined): boolean { + return user?.app_role?.name === 'super_admin'; +} + +function allowsGlobalBypass(permission: PermissionName): boolean { + return !GLOBAL_BYPASS_EXCLUDED_PERMISSIONS.some((name) => name === permission); } export function hasPermission( user: CurrentUser | null | undefined, permission: PermissionName, ): boolean { - return hasGlobalAccess(user) || permissionsOf(user).includes(permission); + return (hasPermissionBypass(user) && allowsGlobalBypass(permission)) + || permissionsOf(user).includes(permission); } export function hasAnyPermission( user: CurrentUser | null | undefined, permissions: readonly PermissionName[], ): boolean { - if (hasGlobalAccess(user)) return true; const granted = permissionsOf(user); - return permissions.some((permission) => granted.includes(permission)); + return permissions.some((permission) => + (hasPermissionBypass(user) && allowsGlobalBypass(permission)) || granted.includes(permission), + ); } export function hasAllPermissions( user: CurrentUser | null | undefined, permissions: readonly PermissionName[], ): boolean { - if (hasGlobalAccess(user)) return true; const granted = permissionsOf(user); - return permissions.every((permission) => granted.includes(permission)); + return permissions.every((permission) => + (hasPermissionBypass(user) && allowsGlobalBypass(permission)) || granted.includes(permission), + ); } diff --git a/frontend/src/business/auth/types.ts b/frontend/src/business/auth/types.ts index 014b9e1..e39da06 100644 --- a/frontend/src/business/auth/types.ts +++ b/frontend/src/business/auth/types.ts @@ -1,5 +1,5 @@ import { CurrentUser } from '@/shared/types/auth'; -import { CampusId, StaffProfile, UserRole } from '@/shared/types/app'; +import { CampusId, UserProfile, UserRole } from '@/shared/types/app'; export interface AuthActionResult { readonly error: string | null; @@ -7,7 +7,7 @@ export interface AuthActionResult { export interface AuthSessionState { readonly user: CurrentUser | null; - readonly profile: StaffProfile | null; + readonly profile: UserProfile | null; readonly loading: boolean; readonly isAuthenticated: boolean; readonly signIn: (email: string, password: string) => Promise; @@ -19,7 +19,9 @@ export interface AuthSessionState { campus: string, ) => Promise; readonly signOut: () => Promise; - readonly updateProfile: (updates: Partial) => Promise; + readonly updateProfile: (updates: Partial) => Promise; + /** Re-fetch the signed-in user from the backend (e.g. after a self-edit). */ + readonly refreshUser: () => Promise; } export type AuthModalMode = 'signin' | 'signup'; diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts index f3e7bcf..22ecea1 100644 --- a/frontend/src/business/campus-attendance/hooks.ts +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -21,7 +21,9 @@ import { } from '@/business/campus-attendance/mappers'; import { buildAttendanceEntryInput, + buildCampusAttendanceScopeModel, buildCampusAttendanceStats, + buildCombinedAttendanceStats, buildOverallAttendanceStats, getToday, getTodayPercentage, @@ -29,21 +31,45 @@ import { getWeekEnd, getWeekStart, } from '@/business/campus-attendance/selectors'; +import { useStaffAttendanceSummary } from '@/business/staff-attendance/hooks'; +import { saveStaffAttendanceRecord } from '@/shared/api/staffAttendance'; import { CAMPUS_ATTENDANCE_PRINT_POPUP_BLOCKED_MESSAGE, openCampusAttendancePrintReport, } from '@/business/campus-attendance/printReport'; import type { + CampusAttendanceChildStats, CampusAttendanceEntryDraft, CampusAttendanceEntryInput, + CampusAttendanceRollupDraft, + AttendanceRosterStatus, + StaffAttendanceEntryDraft, } from '@/business/campus-attendance/types'; import type { CampusId, UserRole } from '@/shared/types/app'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { mapApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import { usePermissions } from '@/shared/app/usePermissions'; +import { useScopeContext } from '@/shared/app/scope-context'; +import { getScopeChildren } from '@/shared/api/scope'; +import type { CampusInfo } from '@/shared/types/app'; +import type { TenantChild, TenantLevel } from '@/shared/types/scope'; +import { listUsers, type AdminUserRow } from '@/shared/api/users'; +import { STAFF_ATTENDANCE_QUERY_KEYS } from '@/shared/constants/staffAttendance'; +import { getClass } from '@/shared/api/classes'; const EMPTY_CONFIGS: ReturnType[] = []; const EMPTY_SUMMARIES: ReturnType[] = []; +const EMPTY_CAMPUSES: CampusInfo[] = []; +const EMPTY_TENANT_CHILDREN: TenantChild[] = []; +const EMPTY_CAMPUS_CHILDREN_BY_PARENT: Readonly> = {}; +const SCHOOL_TILE_GRADIENTS = [ + 'from-emerald-500 to-green-500', + 'from-orange-600 to-amber-500', + 'from-rose-500 to-pink-500', + 'from-violet-500 to-purple-500', + 'from-blue-500 to-cyan-500', +] as const; export function useCampusAttendanceConfigs(campusKey?: CampusAttendanceCampusKey, enabled = true) { return useQuery({ @@ -87,14 +113,29 @@ export function useSaveCampusAttendanceSummary() { }); } +export function useSaveStaffAttendanceRecord() { + return useInvalidatingMutation({ + mutationFn: (input: StaffAttendanceEntryDraft) => saveStaffAttendanceRecord( + input.userId, + input.date, + { status: input.status, note: input.note.trim() || null }, + ), + invalidateQueryKeys: [ + [STAFF_ATTENDANCE_QUERY_KEYS.records], + [STAFF_ATTENDANCE_QUERY_KEYS.summary], + ], + }); +} + type UseCampusAttendancePageInput = { readonly userRole: UserRole; readonly userCampus: string; readonly userName: string; }; -const emptyEntryDraft = (date: string): CampusAttendanceEntryDraft => ({ +const emptyEntryDraft = (date: string, campusId: CampusId = ''): CampusAttendanceEntryDraft => ({ date, + campusId, enrolled: '', present: '', absent: '', @@ -102,44 +143,265 @@ const emptyEntryDraft = (date: string): CampusAttendanceEntryDraft => ({ notes: '', }); +const emptyStaffEntryDraft = (date: string): StaffAttendanceEntryDraft => ({ + date, + userId: '', + status: 'present', + note: '', +}); + +type AttendanceStatusMap = Record; +type StudentRollupOverrideMap = Record>>; + +function userDisplayName(user: AdminUserRow): string { + const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(); + return fullName || user.email; +} + +function reconcileAttendanceStatuses( + current: AttendanceStatusMap, + userIds: readonly string[], +): AttendanceStatusMap { + const next: AttendanceStatusMap = {}; + for (const userId of userIds) { + next[userId] = current[userId] ?? 'present'; + } + return next; +} + +function isBlankRollupRow(row: CampusAttendanceRollupDraft): boolean { + return !row.enrolled && !row.present && !row.absent && !row.tardy && !row.notes.trim(); +} + +function toRollupEntryInput( + row: CampusAttendanceRollupDraft, + date: string, +): CampusAttendanceEntryInput | null { + return buildAttendanceEntryInput( + { + date, + campusId: row.campusId, + enrolled: row.enrolled, + present: row.present, + absent: row.absent, + tardy: row.tardy, + notes: row.notes, + }, + row.campusId, + ); +} + +function isOfficeStaffUser(user: AdminUserRow, mode: 'organization' | 'school' | 'campus'): boolean { + const role = user.app_role?.name; + if (role === 'student' || role === 'guardian') { + return false; + } + + if (mode === 'organization') { + return !user.schoolId && !user.campusId; + } + + if (mode === 'school') { + return Boolean(user.schoolId) && !user.campusId; + } + + return Boolean(user.campusId || user.classId); +} + +function isStaffRosterUser(user: AdminUserRow): boolean { + const role = user.app_role?.name; + return role !== 'student' && role !== 'guardian'; +} + +function mapAttendanceRosterUser(user: AdminUserRow) { + return { + id: user.id, + name: userDisplayName(user), + role: user.app_role?.name ?? null, + }; +} + +interface ScopedAttendanceChildren { + readonly directChildren: readonly TenantChild[]; + readonly campusChildrenByParentId: Readonly>; + readonly campusChildren: readonly TenantChild[]; +} + +async function listScopedAttendanceChildren( + level: TenantLevel, + tenantId: string, +): Promise { + if (level === 'campus') { + return { + directChildren: [], + campusChildrenByParentId: {}, + campusChildren: [], + }; + } + + if (level === 'school') { + const response = await getScopeChildren(level, tenantId, { limit: 500 }); + const campusChildren = response.rows.filter((child) => child.level === 'campus'); + return { + directChildren: campusChildren, + campusChildrenByParentId: Object.fromEntries(campusChildren.map((campus) => [campus.id, [campus]])), + campusChildren, + }; + } + + if (level === 'organization') { + const schoolsResponse = await getScopeChildren(level, tenantId, { limit: 500 }); + const schools = schoolsResponse.rows.filter((child) => child.level === 'school'); + const campusGroups = await Promise.all( + schools.map((school) => getScopeChildren('school', school.id, { limit: 500 })), + ); + const campusChildrenByParentId = Object.fromEntries( + schools.map((school, index) => [ + school.id, + campusGroups[index]?.rows.filter((child) => child.level === 'campus') ?? [], + ]), + ); + return { + directChildren: schools, + campusChildrenByParentId, + campusChildren: Object.values(campusChildrenByParentId).flat(), + }; + } + + return { + directChildren: [], + campusChildrenByParentId: {}, + campusChildren: [], + }; +} + +function percentageFromRecords( + records: readonly ReturnType[], +): number | null { + const enrolled = records.reduce((sum, record) => sum + record.total_enrolled, 0); + const present = records.reduce((sum, record) => sum + record.total_present, 0); + return enrolled > 0 ? Number(((present / enrolled) * 100).toFixed(2)) : null; +} + export function useCampusAttendancePage({ userRole, userCampus, userName, }: UseCampusAttendancePageInput) { + const permissions = usePermissions(); + const { tier, effectiveTenant, selectedTenant } = useScopeContext(); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const selectedCampusId = effectiveTier === 'campus' ? effectiveTenant?.id ?? null : null; + const selectedClassId = effectiveTier === 'class' ? effectiveTenant?.id ?? null : null; const roleAccess = { isSuperintendent: userRole === 'superintendent', isDirector: userRole === 'director', isOfficeManager: userRole === 'office_manager', - canSeeAllCampuses: userRole === 'superintendent', - canEnterData: userRole === 'office_manager', - canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager', + canSeeAllCampuses: effectiveTier === 'organization' || effectiveTier === 'school', + canEnterData: permissions.has('FILL_ATTENDANCE') && ( + effectiveTier === 'organization' + || effectiveTier === 'school' + || effectiveTier === 'campus' + || effectiveTier === 'class' + ), + canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager' || permissions.has('READ_STAFF_ATTENDANCE_REPORTS'), + canReadStaffReports: permissions.has('READ_STAFF_ATTENDANCE_REPORTS'), }; const campusCatalog = useCampusCatalog(); - const campusInfo = findCampusByNameOrCode(campusCatalog.campuses, userCampus); + const classQuery = useQuery({ + queryKey: ['attendance-class-scope', selectedClassId], + enabled: Boolean(selectedClassId), + queryFn: () => getClass(selectedClassId ?? ''), + }); + const classCampusTenantId = classQuery.data?.campusId ?? null; + const scopedCampusTenantId = selectedCampusId ?? classCampusTenantId; + const campusInfo = scopedCampusTenantId + ? campusCatalog.campuses.find((campus) => campus.tenantId === scopedCampusTenantId || campus.id === scopedCampusTenantId) + : findCampusByNameOrCode(campusCatalog.campuses, userCampus); const campusId = campusInfo?.id ?? null; const today = getToday(); const weekStart = getWeekStart(new Date()); const weekEnd = getWeekEnd(new Date()); + const scopeModel = buildCampusAttendanceScopeModel( + effectiveTier, + effectiveTenant, + campusId, + campusInfo?.fullName || userCampus, + ); + const attendanceCampusId = scopeModel.campusId; + const attendanceSummaryFilter = attendanceCampusId ? { campusKey: attendanceCampusId } : undefined; + const scopedAttendanceChildrenQuery = useQuery({ + queryKey: ['attendance-scoped-children', effectiveTier, effectiveTenant?.id ?? null], + enabled: Boolean( + effectiveTenant?.id + && (effectiveTier === 'organization' || effectiveTier === 'school'), + ), + queryFn: () => listScopedAttendanceChildren(effectiveTier as TenantLevel, effectiveTenant?.id ?? ''), + }); - const hasCampusScope = roleAccess.canSeeAllCampuses || Boolean(campusId); + const hasAttendanceScope = scopeModel.mode !== 'campus' || Boolean(attendanceCampusId); const configsQuery = useCampusAttendanceConfigs( - roleAccess.canSeeAllCampuses || !campusId ? undefined : campusId, - hasCampusScope, + attendanceCampusId ?? undefined, + hasAttendanceScope, ); const summariesQuery = useCampusAttendanceSummaries( - roleAccess.canSeeAllCampuses || !campusId ? undefined : { campusKey: campusId }, - hasCampusScope, + attendanceSummaryFilter, + hasAttendanceScope, ); + const staffSummaryQuery = useStaffAttendanceSummary( + { startDate: today, endDate: today }, + roleAccess.canReadStaffReports && hasAttendanceScope, + ); + const officeStaffQuery = useQuery({ + queryKey: ['attendance-office-staff-users', effectiveTier, effectiveTenant?.id ?? null], + enabled: roleAccess.canEnterData && hasAttendanceScope, + queryFn: ({ signal }) => listUsers({ limit: 500, field: 'name', sort: 'asc' }, { signal }), + }); const saveConfigMutation = useSaveCampusAttendanceConfig(); const saveSummaryMutation = useSaveCampusAttendanceSummary(); + const saveStaffAttendanceMutation = useSaveStaffAttendanceRecord(); const configs = configsQuery.data ?? EMPTY_CONFIGS; const attendanceData = summariesQuery.data ?? EMPTY_SUMMARIES; - const loading = campusCatalog.isLoading || configsQuery.isLoading || summariesQuery.isLoading; - const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending; - const loadError = campusCatalog.error ?? configsQuery.error ?? summariesQuery.error; - const saveError = saveConfigMutation.error ?? saveSummaryMutation.error; + const staffSummary = staffSummaryQuery.data ?? null; + const officeStaffUsers = useMemo(() => ( + (officeStaffQuery.data?.rows ?? []) + .filter((user) => isOfficeStaffUser(user, scopeModel.mode) && isStaffRosterUser(user)) + .map(mapAttendanceRosterUser) + ), [officeStaffQuery.data?.rows, scopeModel.mode]); + const scopedAttendanceChildren = scopedAttendanceChildrenQuery.data; + const scopedCampusTenants = scopedAttendanceChildren?.campusChildren; + const scopedDirectChildren = scopedAttendanceChildren?.directChildren ?? EMPTY_TENANT_CHILDREN; + const campusChildrenByParentId = scopedAttendanceChildren?.campusChildrenByParentId ?? EMPTY_CAMPUS_CHILDREN_BY_PARENT; + const scopedCampusOptions = useMemo(() => { + if (scopeModel.mode === 'campus') { + return campusInfo ? [campusInfo] : EMPTY_CAMPUSES; + } + + const scopedTenantIds = new Set((scopedCampusTenants ?? EMPTY_TENANT_CHILDREN).map((campus) => campus.id)); + return campusCatalog.campuses.filter((campus) => ( + campus.tenantId ? scopedTenantIds.has(campus.tenantId) : false + )); + }, [campusCatalog.campuses, campusInfo, scopeModel.mode, scopedCampusTenants]); + const loading = campusCatalog.isLoading + || (effectiveTier === 'class' && classQuery.isLoading) + || configsQuery.isLoading + || summariesQuery.isLoading + || (roleAccess.canReadStaffReports && staffSummaryQuery.isLoading) + || (roleAccess.canEnterData && officeStaffQuery.isLoading) + || (roleAccess.canSeeAllCampuses && scopedAttendanceChildrenQuery.isLoading); + const saving = saveConfigMutation.isPending || saveSummaryMutation.isPending || saveStaffAttendanceMutation.isPending; + const loadError = campusCatalog.error + ?? (effectiveTier === 'class' ? classQuery.error : null) + ?? configsQuery.error + ?? summariesQuery.error + ?? (roleAccess.canReadStaffReports ? staffSummaryQuery.error : null) + ?? (roleAccess.canEnterData ? officeStaffQuery.error : null) + ?? (roleAccess.canSeeAllCampuses ? scopedAttendanceChildrenQuery.error : null); + const saveError = saveConfigMutation.error ?? saveSummaryMutation.error ?? saveStaffAttendanceMutation.error; const [successMessage, setSuccessMessage] = useState(''); const [printError, setPrintError] = useState(null); const errorMessage = printError ?? getOptionalErrorMessage(loadError ?? saveError); @@ -147,22 +409,195 @@ export function useCampusAttendancePage({ const [linkValue, setLinkValue] = useState(''); const [showEntryForm, setShowEntryForm] = useState(false); const [expandedCampus, setExpandedCampus] = useState(null); - const [entryDraft, setEntryDraft] = useState(() => emptyEntryDraft(today)); + const [entryDraft, setEntryDraft] = useState(() => emptyEntryDraft(today, attendanceCampusId ?? '')); + const [staffEntryDraft, setStaffEntryDraft] = useState(() => emptyStaffEntryDraft(today)); const [entryError, setEntryError] = useState(null); + const [staffEntryError, setStaffEntryError] = useState(null); + const [studentAttendanceStatusOverrides, setStudentAttendanceStatusOverrides] = useState({}); + const [staffAttendanceStatusOverrides, setStaffAttendanceStatusOverrides] = useState({}); + const [studentRollupOverrides, setStudentRollupOverrides] = useState({}); const campusStats = useMemo( () => buildCampusAttendanceStats(campusCatalog.campuses, configs, attendanceData, today, weekStart, weekEnd), [attendanceData, campusCatalog.campuses, configs, today, weekEnd, weekStart], ); + const visibleCampusStats = useMemo(() => { + if (scopeModel.mode === 'campus') { + return attendanceCampusId + ? campusStats.filter((campus) => campus.id === attendanceCampusId) + : []; + } + + const scopedCampusIds = new Set([ + ...scopedCampusOptions.map((campus) => campus.id), + ...configs.map((config) => config.campus_id), + ...attendanceData.map((record) => record.campus_id), + ]); + + return campusStats.filter((campus) => scopedCampusIds.has(campus.id)); + }, [attendanceCampusId, attendanceData, campusStats, configs, scopeModel.mode, scopedCampusOptions]); + const attendanceChildStats = useMemo((): readonly CampusAttendanceChildStats[] => { + if (scopeModel.mode === 'campus') { + return visibleCampusStats.map((campus) => ({ + id: campus.tenantId ?? campus.id, + level: 'campus', + mascot: campus.mascot, + fullName: campus.fullName, + bgGradient: campus.bgGradient, + isOnline: campus.isOnline, + todayPct: campus.todayPct, + weekAvg: campus.weekAvg, + recentData: campus.recentData, + todayRecord: campus.todayRecord, + childCampusIds: [campus.id], + })); + } + + if (scopeModel.mode === 'school') { + return scopedDirectChildren + .filter((child) => child.level === 'campus') + .map((child, index) => { + const campus = visibleCampusStats.find((item) => item.tenantId === child.id) + ?? campusStats.find((item) => item.tenantId === child.id); + return { + id: child.id, + level: child.level, + mascot: campus?.mascot ?? child.name ?? 'Campus', + fullName: campus?.fullName ?? child.name ?? 'Campus', + bgGradient: campus?.bgGradient ?? SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length], + isOnline: campus?.isOnline, + todayPct: campus?.todayPct ?? null, + weekAvg: campus?.weekAvg ?? null, + recentData: campus?.recentData ?? [], + todayRecord: campus?.todayRecord ?? null, + childCampusIds: campus ? [campus.id] : [], + }; + }); + } + + return scopedDirectChildren + .filter((child) => child.level === 'school') + .map((child, index) => { + const childCampuses = (campusChildrenByParentId[child.id] ?? []) + .map((campusChild) => campusCatalog.campuses.find((campus) => campus.tenantId === campusChild.id)) + .filter((campus): campus is CampusInfo => Boolean(campus)); + const childCampusIds = childCampuses.map((campus) => campus.id); + const childTodayRecords = attendanceData.filter((record) => ( + childCampusIds.includes(record.campus_id) && record.date === today + )); + const childWeekRecords = attendanceData.filter((record) => ( + childCampusIds.includes(record.campus_id) + && record.date >= weekStart + && record.date <= weekEnd + )); + const weekDays = Array.from(new Set(childWeekRecords.map((record) => record.date))); + const weekAvg = weekDays.length > 0 + ? Number((weekDays.reduce((sum, day) => ( + sum + (percentageFromRecords(childWeekRecords.filter((record) => record.date === day)) ?? 0) + ), 0) / weekDays.length).toFixed(2)) + : null; + + return { + id: child.id, + level: child.level, + mascot: child.name ?? 'School', + fullName: child.name ?? 'School', + bgGradient: SCHOOL_TILE_GRADIENTS[index % SCHOOL_TILE_GRADIENTS.length], + todayPct: percentageFromRecords(childTodayRecords), + weekAvg, + recentData: childWeekRecords.slice(0, 10), + todayRecord: null, + childCampusIds, + }; + }); + }, [ + attendanceData, + campusCatalog.campuses, + campusChildrenByParentId, + campusStats, + scopeModel.mode, + scopedDirectChildren, + today, + visibleCampusStats, + weekEnd, + weekStart, + ]); const overallStats = useMemo( () => buildOverallAttendanceStats(attendanceData, today, weekStart, weekEnd), [attendanceData, today, weekEnd, weekStart], ); - const myCampusConfig = campusId ? configs.find((config) => config.campus_id === campusId) : undefined; - const myCampusData = campusId ? attendanceData.filter((record) => record.campus_id === campusId) : []; - const myTodayPct = campusId ? getTodayPercentage(attendanceData, campusId, today) : null; - const myWeekAvg = campusId ? getWeeklyAverage(attendanceData, campusId, weekStart, weekEnd) : null; + const combinedStats = useMemo( + () => buildCombinedAttendanceStats(overallStats, staffSummary), + [overallStats, staffSummary], + ); + const myCampusConfig = attendanceCampusId ? configs.find((config) => config.campus_id === attendanceCampusId) : undefined; + const myCampusData = attendanceCampusId ? attendanceData.filter((record) => record.campus_id === attendanceCampusId) : []; + const myTodayPct = attendanceCampusId ? getTodayPercentage(attendanceData, attendanceCampusId, today) : null; + const myWeekAvg = attendanceCampusId ? getWeeklyAverage(attendanceData, attendanceCampusId, weekStart, weekEnd) : null; + const selectedEntryCampus = scopedCampusOptions.find((campus) => campus.id === entryDraft.campusId); + const selectedEntryCampusTenantId = scopeModel.mode === 'campus' + ? scopedCampusTenantId + : selectedEntryCampus?.tenantId ?? null; + const selectedEntryClassId = scopeModel.mode === 'campus' ? selectedClassId : null; + const attendanceStudentsQuery = useQuery({ + queryKey: ['attendance-student-users', selectedEntryCampusTenantId, selectedEntryClassId], + enabled: roleAccess.canEnterData + && showEntryForm + && Boolean(selectedEntryClassId || selectedEntryCampusTenantId), + queryFn: ({ signal }) => listUsers({ + app_role: 'student', + classId: selectedEntryClassId ?? undefined, + campusId: selectedEntryClassId ? undefined : selectedEntryCampusTenantId ?? undefined, + limit: 1000, + field: 'name', + sort: 'asc', + }, { signal }), + }); + const attendanceStudents = useMemo(() => ( + (attendanceStudentsQuery.data?.rows ?? []).map((user) => ({ + id: user.id, + name: userDisplayName(user), + role: user.app_role?.name ?? null, + })) + ), [attendanceStudentsQuery.data?.rows]); + const studentAttendanceStatuses = useMemo( + () => reconcileAttendanceStatuses( + studentAttendanceStatusOverrides, + attendanceStudents.map((student) => student.id), + ), + [attendanceStudents, studentAttendanceStatusOverrides], + ); + const staffAttendanceStatuses = useMemo( + () => reconcileAttendanceStatuses( + staffAttendanceStatusOverrides, + officeStaffUsers.map((staff) => staff.id), + ), + [officeStaffUsers, staffAttendanceStatusOverrides], + ); + const studentRollupRows = useMemo((): readonly CampusAttendanceRollupDraft[] => { + if (scopeModel.mode === 'campus') { + return []; + } + + return scopedCampusOptions.map((campus) => { + const todayRecord = attendanceData.find((record) => ( + record.campus_id === campus.id && record.date === entryDraft.date + )); + const override = studentRollupOverrides[campus.id] ?? {}; + + return { + campusId: campus.id, + campusName: campus.fullName, + enrolled: override.enrolled ?? (todayRecord ? String(todayRecord.total_enrolled) : ''), + present: override.present ?? (todayRecord ? String(todayRecord.total_present) : ''), + absent: override.absent ?? (todayRecord ? String(todayRecord.total_absent) : ''), + tardy: override.tardy ?? (todayRecord ? String(todayRecord.total_tardy) : ''), + notes: override.notes ?? todayRecord?.notes ?? '', + hasRecordedData: Boolean(todayRecord), + }; + }); + }, [attendanceData, entryDraft.date, scopeModel.mode, scopedCampusOptions, studentRollupOverrides]); const showSuccess = (message: string) => { setPrintError(null); setSuccessMessage(message); @@ -174,6 +609,47 @@ export function useCampusAttendancePage({ setEntryError(null); }; + const updateStaffEntryDraft = (patch: Partial) => { + setStaffEntryDraft((currentDraft) => ({ ...currentDraft, ...patch })); + setStaffEntryError(null); + }; + + const updateStudentAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => { + setStudentAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status })); + setEntryError(null); + }; + + const updateStudentRollupDraft = ( + campusIdToUpdate: CampusId, + patch: Partial>, + ) => { + setStudentRollupOverrides((currentOverrides) => ({ + ...currentOverrides, + [campusIdToUpdate]: { + ...currentOverrides[campusIdToUpdate], + ...patch, + }, + })); + setEntryError(null); + }; + + const updateStaffAttendanceStatus = (userId: string, status: AttendanceRosterStatus) => { + setStaffAttendanceStatusOverrides((currentStatuses) => ({ ...currentStatuses, [userId]: status })); + setStaffEntryError(null); + }; + + const setEntryFormVisibility = (nextShowEntryForm: boolean) => { + if (nextShowEntryForm) { + setEntryDraft((currentDraft) => ({ + ...currentDraft, + campusId: attendanceCampusId ?? currentDraft.campusId, + })); + } + setEntryError(null); + setStaffEntryError(null); + setShowEntryForm(nextShowEntryForm); + }; + const handleSaveLink = async (targetCampusId: CampusId) => { setPrintError(null); await saveConfigMutation.mutateAsync({ @@ -184,40 +660,164 @@ export function useCampusAttendancePage({ setEditingLink(null); }; - const handleSubmitEntry = async () => { - setPrintError(null); + const saveStudentAttendance = async (): Promise => { + if (scopeModel.mode !== 'campus') { + const rowsToSave: CampusAttendanceEntryInput[] = []; + let hasInvalidRow = false; - if (!campusId) { - setEntryError('Campus is unavailable. Refresh the campus catalog before saving attendance.'); - return; + for (const row of studentRollupRows) { + if (isBlankRollupRow(row)) { + continue; + } + + const input = toRollupEntryInput(row, entryDraft.date); + if (!input) { + hasInvalidRow = true; + break; + } + rowsToSave.push(input); + } + + if (hasInvalidRow) { + setEntryError('Each campus row needs enrolled, present, and absent counts before saving.'); + return false; + } + + if (rowsToSave.length === 0) { + setEntryError('Enter at least one campus attendance row before saving.'); + return false; + } + + for (const input of rowsToSave) { + await saveSummaryMutation.mutateAsync(input); + } + + setStudentRollupOverrides({}); + return true; } - const input = buildAttendanceEntryInput(entryDraft, campusId); + const targetCampusId = attendanceCampusId ?? entryDraft.campusId; + + if (!targetCampusId) { + setEntryError('Select a campus before saving attendance.'); + return false; + } + + const input = attendanceStudents.length > 0 + ? { + campusId: targetCampusId, + date: entryDraft.date, + totalEnrolled: attendanceStudents.length, + totalPresent: attendanceStudents.filter((student) => ( + (studentAttendanceStatuses[student.id] ?? 'present') !== 'absent' + )).length, + totalAbsent: attendanceStudents.filter((student) => ( + (studentAttendanceStatuses[student.id] ?? 'present') === 'absent' + )).length, + totalTardy: attendanceStudents.filter((student) => ( + (studentAttendanceStatuses[student.id] ?? 'present') === 'late' + )).length, + notes: entryDraft.notes.trim() || null, + } + : buildAttendanceEntryInput(entryDraft, targetCampusId); if (!input) { setEntryError('Enter enrolled, present, and absent counts before saving.'); - return; + return false; } await saveSummaryMutation.mutateAsync(input); - showSuccess('Attendance data saved!'); + setEntryDraft(emptyEntryDraft(today, attendanceCampusId ?? targetCampusId)); + return true; + }; + + const saveStaffBatchAttendance = async (requireStaff: boolean): Promise => { + if (officeStaffUsers.length === 0) { + if (requireStaff) { + setStaffEntryError('No office staff are available for this scope.'); + } + return !requireStaff; + } + + for (const staffUser of officeStaffUsers) { + await saveStaffAttendanceMutation.mutateAsync({ + date: staffEntryDraft.date, + userId: staffUser.id, + status: staffAttendanceStatuses[staffUser.id] ?? 'present', + note: staffEntryDraft.note, + }); + } + + setStaffEntryDraft(emptyStaffEntryDraft(today)); + return true; + }; + + const handleSubmitEntry = async () => { + setPrintError(null); + + const saved = await saveStudentAttendance(); + if (!saved) { + return; + } + + showSuccess('Student attendance saved!'); + setShowEntryForm(false); + }; + + const handleSubmitStaffEntry = async () => { + setPrintError(null); + + if (!staffEntryDraft.userId) { + setStaffEntryError('Select an office staff member before saving attendance.'); + return; + } + + await saveStaffAttendanceMutation.mutateAsync(staffEntryDraft); + showSuccess('Office staff attendance saved!'); + setStaffEntryDraft(emptyStaffEntryDraft(today)); + }; + + const handleSubmitStaffBatch = async () => { + setPrintError(null); + + const saved = await saveStaffBatchAttendance(true); + if (!saved) { + return; + } + + showSuccess('Staff attendance saved!'); + }; + + const handleSubmitAttendanceForm = async () => { + setPrintError(null); + + const studentSaved = await saveStudentAttendance(); + if (!studentSaved) { + return; + } + + const staffSaved = await saveStaffBatchAttendance(false); + if (!staffSaved) { + return; + } + + showSuccess('Attendance saved!'); setShowEntryForm(false); - setEntryDraft(emptyEntryDraft(today)); }; const handlePrint = () => { const campusesToPrint = roleAccess.canSeeAllCampuses - ? campusStats - : campusStats.filter((campus) => campus.id === campusId); + ? visibleCampusStats + : campusStats.filter((campus) => campus.id === attendanceCampusId); const reportTitle = roleAccess.canSeeAllCampuses - ? 'All Campuses Attendance Report' + ? `${scopeModel.reportLabel} Attendance Report` : `${campusInfo?.fullName || userCampus} Attendance Report`; const printTodayRecords = roleAccess.canSeeAllCampuses ? overallStats.todayRecords - : attendanceData.filter((record) => record.campus_id === campusId && record.date === today); + : attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date === today); const printWeekRecords = roleAccess.canSeeAllCampuses ? overallStats.weekRecords - : attendanceData.filter((record) => record.campus_id === campusId && record.date >= weekStart && record.date <= weekEnd); + : attendanceData.filter((record) => record.campus_id === attendanceCampusId && record.date >= weekStart && record.date <= weekEnd); const printResult = openCampusAttendancePrintReport({ input: { @@ -243,8 +843,9 @@ export function useCampusAttendancePage({ return { state: { roleAccess, + scopeModel, campusInfo, - campusId, + campusId: attendanceCampusId, today, weekStart, weekEnd, @@ -260,8 +861,24 @@ export function useCampusAttendancePage({ expandedCampus, entryDraft, entryError, - campusStats, + staffEntryDraft, + staffEntryError, + officeStaffUsers, + staffAttendanceStatuses, + studentRollupRows, + attendanceStudents, + studentAttendanceStatuses, + attendanceStudentsLoading: attendanceStudentsQuery.isLoading, + scopedCampusOptions, + campusStats: visibleCampusStats, + attendanceChildStats, overallStats, + combinedStats, + staffSummary: { + summary: staffSummary, + loading: staffSummaryQuery.isLoading, + error: staffSummaryQuery.error, + }, myCampusConfig, myCampusData, myTodayPct, @@ -271,11 +888,18 @@ export function useCampusAttendancePage({ actions: { setEditingLink, setLinkValue, - setShowEntryForm, + setShowEntryForm: setEntryFormVisibility, setExpandedCampus, updateEntryDraft, + updateStaffEntryDraft, + updateStudentAttendanceStatus, + updateStudentRollupDraft, + updateStaffAttendanceStatus, handleSaveLink, handleSubmitEntry, + handleSubmitStaffEntry, + handleSubmitStaffBatch, + handleSubmitAttendanceForm, handlePrint, }, }; diff --git a/frontend/src/business/campus-attendance/selectors.test.ts b/frontend/src/business/campus-attendance/selectors.test.ts index 72597c9..b641de8 100644 --- a/frontend/src/business/campus-attendance/selectors.test.ts +++ b/frontend/src/business/campus-attendance/selectors.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildAttendanceEntryInput, + buildCampusAttendanceScopeModel, + buildCombinedAttendanceStats, buildOverallAttendanceStats, getWeekEnd, getWeekStart, @@ -60,6 +62,7 @@ describe('campus attendance selectors', () => { it('builds validated attendance input and normalizes optional notes', () => { const draft: CampusAttendanceEntryDraft = { date: '2026-06-08', + campusId: 'tigers', enrolled: '25', present: '21', absent: '4', @@ -81,6 +84,7 @@ describe('campus attendance selectors', () => { it('rejects attendance input without a positive enrollment count', () => { const draft: CampusAttendanceEntryDraft = { date: '2026-06-08', + campusId: 'tigers', enrolled: '0', present: '0', absent: '0', @@ -101,4 +105,74 @@ describe('campus attendance selectors', () => { weekPct: 90.83, }); }); + + it('builds scope-aware attendance titles and descriptions', () => { + expect( + buildCampusAttendanceScopeModel( + 'organization', + { level: 'organization', id: 'org-1', name: 'Demo Academy', logo: null }, + null, + 'Tigers Campus', + ), + ).toMatchObject({ + mode: 'organization', + title: 'Organization Attendance', + reportLabel: 'Organization', + }); + + expect( + buildCampusAttendanceScopeModel( + 'school', + { level: 'school', id: 'school-1', name: 'Demo Academy North', logo: null }, + null, + 'Tigers Campus', + ), + ).toMatchObject({ + mode: 'school', + title: 'School Attendance', + reportLabel: 'School', + }); + + expect( + buildCampusAttendanceScopeModel('campus', null, 'campus-1', 'Tigers Campus'), + ).toMatchObject({ + mode: 'campus', + title: 'Campus Attendance', + reportLabel: 'Campus', + campusId: 'campus-1', + }); + + expect( + buildCampusAttendanceScopeModel( + 'class', + { level: 'class', id: 'class-1', name: 'Tigers Homeroom 1', logo: null }, + 'campus-1', + 'Tigers Campus', + ), + ).toMatchObject({ + mode: 'campus', + title: 'Classroom Attendance', + reportLabel: 'Classroom', + campusId: 'campus-1', + }); + }); + + it('combines student attendance aggregates with staff attendance summary', () => { + const overallStats = buildOverallAttendanceStats(summaries, '2026-06-08', '2026-06-08', '2026-06-12'); + + expect( + buildCombinedAttendanceStats(overallStats, { + staffCount: 12, + recordsCount: 10, + present: 8, + late: 1, + absent: 1, + }), + ).toEqual({ + studentTodayPct: 86.67, + studentWeekPct: 90.83, + staffTodayPct: 90, + combinedTodayPct: 86.88, + }); + }); }); diff --git a/frontend/src/business/campus-attendance/selectors.ts b/frontend/src/business/campus-attendance/selectors.ts index fd56907..6e22211 100644 --- a/frontend/src/business/campus-attendance/selectors.ts +++ b/frontend/src/business/campus-attendance/selectors.ts @@ -1,12 +1,18 @@ import type { CampusId, CampusInfo } from '@/shared/types/app'; +import type { ScopeTier } from '@/business/scope/selectors'; +import type { ActiveTenant } from '@/shared/types/scope'; import type { + AttendanceScopeMode, + CampusAttendanceCombinedStats, CampusAttendanceEntryDraft, CampusAttendanceConfigViewModel, CampusAttendanceOverallStats, CampusAttendancePrintInput, + CampusAttendanceScopeModel, CampusAttendanceStats, CampusAttendanceSummaryViewModel, } from '@/business/campus-attendance/types'; +import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types'; export function getWeekStart(date: Date): string { const weekStart = new Date(date); @@ -176,6 +182,104 @@ export function buildOverallAttendanceStats( }; } +function getAttendanceScopeMode(tier: ScopeTier): AttendanceScopeMode { + if (tier === 'organization') { + return 'organization'; + } + if (tier === 'school') { + return 'school'; + } + return 'campus'; +} + +export function buildCampusAttendanceScopeModel( + tier: ScopeTier, + effectiveTenant: ActiveTenant | null, + campusId: CampusId | null, + campusName: string, +): CampusAttendanceScopeModel { + const mode = getAttendanceScopeMode(tier); + const tenantName = effectiveTenant?.name ?? null; + const displayName = tenantName ?? campusName; + + if (mode === 'organization') { + return { + tier, + mode, + title: 'Organization Attendance', + description: `${displayName} attendance across all schools, campuses, and organization office staff`, + aggregateHelper: 'Across organization scope', + reportLabel: 'Organization', + emptyHistoryMessage: 'No attendance data recorded yet for this organization.', + campusId: null, + tenantName, + }; + } + + if (mode === 'school') { + return { + tier, + mode, + title: 'School Attendance', + description: `${displayName} attendance across all campuses and school staff`, + aggregateHelper: 'Across school scope', + reportLabel: 'School', + emptyHistoryMessage: 'No attendance data recorded yet for this school.', + campusId: null, + tenantName, + }; + } + + if (tier === 'class') { + return { + tier, + mode, + title: 'Classroom Attendance', + description: `${displayName} attendance overview`, + aggregateHelper: 'Current classroom', + reportLabel: 'Classroom', + emptyHistoryMessage: 'No attendance data recorded yet for this classroom.', + campusId, + tenantName, + }; + } + + return { + tier, + mode, + title: 'Campus Attendance', + description: `${displayName} attendance overview`, + aggregateHelper: 'Current campus', + reportLabel: 'Campus', + emptyHistoryMessage: 'No attendance data recorded yet for this campus.', + campusId, + tenantName, + }; +} + +function percentageFromCounts(present: number, total: number): number | null { + return total > 0 ? Number(((present / total) * 100).toFixed(2)) : null; +} + +export function buildCombinedAttendanceStats( + overallStats: CampusAttendanceOverallStats, + staffSummary: StaffAttendanceSummaryViewModel | null, +): CampusAttendanceCombinedStats { + const staffTotal = staffSummary + ? staffSummary.present + staffSummary.late + staffSummary.absent + : 0; + const staffPresent = staffSummary ? staffSummary.present + staffSummary.late : 0; + const studentPresent = overallStats.todayPresent; + const studentTotal = overallStats.todayEnrolled; + + return { + studentTodayPct: overallStats.todayPct, + studentWeekPct: overallStats.weekPct, + staffTodayPct: percentageFromCounts(staffPresent, staffTotal), + combinedTodayPct: percentageFromCounts(studentPresent + staffPresent, studentTotal + staffTotal), + }; +} + export function buildPrintAttendanceStats(input: CampusAttendancePrintInput) { const printEnrolled = input.printTodayRecords.reduce((sum, record) => sum + record.total_enrolled, 0); const printPresent = input.printTodayRecords.reduce((sum, record) => sum + record.total_present, 0); diff --git a/frontend/src/business/campus-attendance/types.ts b/frontend/src/business/campus-attendance/types.ts index a9d0940..a0b03e2 100644 --- a/frontend/src/business/campus-attendance/types.ts +++ b/frontend/src/business/campus-attendance/types.ts @@ -1,4 +1,10 @@ import type { CampusId, CampusInfo } from '@/shared/types/app'; +import type { ScopeTier } from '@/business/scope/selectors'; +import type { StaffAttendanceStatus } from '@/shared/types/staffAttendance'; +import type { StaffAttendanceSummaryViewModel } from '@/business/staff-attendance/types'; +import type { TenantLevel } from '@/shared/types/scope'; + +export type AttendanceScopeMode = 'organization' | 'school' | 'campus'; export interface CampusAttendanceConfigViewModel { readonly id: string; @@ -39,6 +45,20 @@ export interface CampusAttendanceStats extends CampusInfo { readonly todayRecord: CampusAttendanceSummaryViewModel | null; } +export interface CampusAttendanceChildStats { + readonly id: string; + readonly level: TenantLevel; + readonly mascot: string; + readonly fullName: string; + readonly bgGradient: string; + readonly isOnline?: boolean; + readonly todayPct: number | null; + readonly weekAvg: number | null; + readonly recentData: readonly CampusAttendanceSummaryViewModel[]; + readonly todayRecord: CampusAttendanceSummaryViewModel | null; + readonly childCampusIds: readonly CampusId[]; +} + export interface CampusAttendanceOverallStats { readonly todayRecords: readonly CampusAttendanceSummaryViewModel[]; readonly todayEnrolled: number; @@ -50,6 +70,7 @@ export interface CampusAttendanceOverallStats { export interface CampusAttendanceEntryDraft { readonly date: string; + readonly campusId: CampusId; readonly enrolled: string; readonly present: string; readonly absent: string; @@ -57,6 +78,32 @@ export interface CampusAttendanceEntryDraft { readonly notes: string; } +export interface StaffAttendanceEntryDraft { + readonly date: string; + readonly userId: string; + readonly status: StaffAttendanceStatus; + readonly note: string; +} + +export interface AttendanceRosterUser { + readonly id: string; + readonly name: string; + readonly role: string | null; +} + +export type AttendanceRosterStatus = StaffAttendanceStatus; + +export interface CampusAttendanceRollupDraft { + readonly campusId: CampusId; + readonly campusName: string; + readonly enrolled: string; + readonly present: string; + readonly absent: string; + readonly tardy: string; + readonly notes: string; + readonly hasRecordedData: boolean; +} + export interface CampusAttendanceRoleAccess { readonly isSuperintendent: boolean; readonly isDirector: boolean; @@ -64,6 +111,32 @@ export interface CampusAttendanceRoleAccess { readonly canSeeAllCampuses: boolean; readonly canEnterData: boolean; readonly canPrint: boolean; + readonly canReadStaffReports: boolean; +} + +export interface CampusAttendanceScopeModel { + readonly tier: ScopeTier; + readonly mode: AttendanceScopeMode; + readonly title: string; + readonly description: string; + readonly aggregateHelper: string; + readonly reportLabel: string; + readonly emptyHistoryMessage: string; + readonly campusId: CampusId | null; + readonly tenantName: string | null; +} + +export interface CampusAttendanceCombinedStats { + readonly studentTodayPct: number | null; + readonly studentWeekPct: number | null; + readonly staffTodayPct: number | null; + readonly combinedTodayPct: number | null; +} + +export interface CampusAttendanceStaffSummaryState { + readonly summary: StaffAttendanceSummaryViewModel | null; + readonly loading: boolean; + readonly error: unknown; } export interface CampusAttendancePrintInput { diff --git a/frontend/src/business/campuses/mappers.test.ts b/frontend/src/business/campuses/mappers.test.ts index cfb3736..5e261a7 100644 --- a/frontend/src/business/campuses/mappers.test.ts +++ b/frontend/src/business/campuses/mappers.test.ts @@ -9,6 +9,7 @@ describe('campus catalog mappers', () => { id: 'tigers', mascot: 'Tigers', fullName: 'Tigers Campus', + tenantId: 'campus-1', color: 'bg-orange-500', bgGradient: 'from-orange-500 to-amber-500', borderColor: 'border-orange-500/30', diff --git a/frontend/src/business/classroom-support/hooks.ts b/frontend/src/business/classroom-support/hooks.ts index 90079b7..16a9b37 100644 --- a/frontend/src/business/classroom-support/hooks.ts +++ b/frontend/src/business/classroom-support/hooks.ts @@ -1,13 +1,15 @@ import { useMemo, useState } from 'react'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { filterClassroomSupportStrategies, getClassroomSupportDailyStrategy, - toggleClassroomSupportFavorite, } from '@/business/classroom-support/selectors'; import type { ClassroomSupportPage } from '@/business/classroom-support/types'; +import { useClassroomStrategyFavorites } from '@/business/user-progress/hooks'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { useScopeContext } from '@/shared/app/scope-context'; import type { ClassroomSupportAgeFilter, ClassroomSupportCategoryFilter, @@ -16,11 +18,13 @@ import type { import type { Strategy } from '@/shared/types/app'; export function useClassroomSupportPage(now: Date = new Date()): ClassroomSupportPage { + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistFavorites = canPersistPersonalScopeResults(ownTenant, selectedTenant); const strategiesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.classroomStrategies, [], ); - const [favoriteStrategyIds, setFavoriteStrategyIds] = useState>(new Set()); + const favorites = useClassroomStrategyFavorites({ enabled: canPersistFavorites }); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [ageFilter, setAgeFilter] = useState('all'); @@ -39,8 +43,8 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor [ageFilter, categoryFilter, searchQuery, showFavoritesOnly, zoneFilter], ); const filteredStrategies = useMemo( - () => filterClassroomSupportStrategies(strategies, filters, favoriteStrategyIds), - [favoriteStrategyIds, filters, strategies], + () => filterClassroomSupportStrategies(strategies, filters, favorites.favoriteStrategyIds), + [favorites.favoriteStrategyIds, filters, strategies], ); const tryTodayStrategy = useMemo( () => getClassroomSupportDailyStrategy(strategies, now), @@ -52,7 +56,12 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor } function toggleFavorite(id: string) { - setFavoriteStrategyIds((current) => toggleClassroomSupportFavorite(current, id)); + const strategy = strategies.find((item) => item.id === id); + if (!strategy || !canPersistFavorites) { + return; + } + + void favorites.toggleFavoriteStrategy(id, strategy.title); } function clearSearch() { @@ -62,8 +71,11 @@ export function useClassroomSupportPage(now: Date = new Date()): ClassroomSuppor return { strategies, filteredStrategies, - favoriteStrategyIds, - favoriteCount: favoriteStrategyIds.size, + favoriteStrategyIds: favorites.favoriteStrategyIds, + favoriteCount: favorites.favoriteStrategyIds.size, + canPersistFavorites, + isSavingFavorite: favorites.isSaving, + favoriteError: favorites.error, filters, selectedStrategy, tryTodayStrategy, diff --git a/frontend/src/business/classroom-support/selectors.test.ts b/frontend/src/business/classroom-support/selectors.test.ts index 287b4e3..b903d56 100644 --- a/frontend/src/business/classroom-support/selectors.test.ts +++ b/frontend/src/business/classroom-support/selectors.test.ts @@ -6,7 +6,6 @@ import { toClassroomSupportAgeFilter, toClassroomSupportCategoryFilter, toClassroomSupportZoneFilter, - toggleClassroomSupportFavorite, } from '@/business/classroom-support/selectors'; import type { ClassroomSupportFilters } from '@/business/classroom-support/types'; import type { Strategy } from '@/shared/types/app'; @@ -50,6 +49,25 @@ describe('classroom support selectors', () => { expect(filterClassroomSupportStrategies(strategies, createFilters({ showFavoritesOnly: true }), new Set(['visual'])).map((strategy) => strategy.id)).toEqual(['visual']); }); + it('matches search terms against strategy titles and descriptions', () => { + const strategies = [ + createStrategy({ + id: 'tokens', + title: 'Token Economy System', + description: 'Reward system using tokens earned for desired behaviors.', + }), + createStrategy({ + id: 'breaks', + title: 'Sensory Break Station', + description: 'Create a calm station for self-regulation.', + }), + ]; + + expect(filterClassroomSupportStrategies(strategies, createFilters({ searchQuery: 'economy' }), new Set()).map((strategy) => strategy.id)).toEqual(['tokens']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ searchQuery: 'desired behavior' }), new Set()).map((strategy) => strategy.id)).toEqual(['tokens']); + expect(filterClassroomSupportStrategies(strategies, createFilters({ searchQuery: 'sensory regulation' }), new Set()).map((strategy) => strategy.id)).toEqual(['breaks']); + }); + it('selects a deterministic daily strategy', () => { const strategies = [ createStrategy({ id: 'first' }), @@ -61,13 +79,6 @@ describe('classroom support selectors', () => { expect(getClassroomSupportDailyStrategy([], new Date(0))).toBeNull(); }); - it('toggles favorite IDs immutably', () => { - const current = new Set(['strategy-1']); - - expect(Array.from(toggleClassroomSupportFavorite(current, 'strategy-1'))).toEqual([]); - expect(Array.from(toggleClassroomSupportFavorite(current, 'strategy-2'))).toEqual(['strategy-1', 'strategy-2']); - }); - it('normalizes invalid filter values', () => { expect(toClassroomSupportCategoryFilter('sensory')).toBe('sensory'); expect(toClassroomSupportCategoryFilter('bad')).toBe('all'); diff --git a/frontend/src/business/classroom-support/selectors.ts b/frontend/src/business/classroom-support/selectors.ts index 81132ac..8ed8039 100644 --- a/frontend/src/business/classroom-support/selectors.ts +++ b/frontend/src/business/classroom-support/selectors.ts @@ -12,13 +12,16 @@ export function filterClassroomSupportStrategies( filters: ClassroomSupportFilters, favoriteStrategyIds: ReadonlySet, ): readonly Strategy[] { - const normalizedSearch = filters.searchQuery.trim().toLowerCase(); + const searchParts = filters.searchQuery + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); return strategies.filter((strategy) => { if ( - normalizedSearch.length > 0 - && !strategy.title.toLowerCase().includes(normalizedSearch) - && !strategy.description.toLowerCase().includes(normalizedSearch) + searchParts.length > 0 + && !strategyMatchesSearch(strategy, searchParts) ) { return false; } @@ -43,6 +46,11 @@ export function filterClassroomSupportStrategies( }); } +function strategyMatchesSearch(strategy: Strategy, searchParts: readonly string[]): boolean { + const searchableText = `${strategy.title} ${strategy.description}`.toLowerCase(); + return searchParts.every((part) => searchableText.includes(part)); +} + export function getClassroomSupportDailyStrategy( strategies: readonly Strategy[], now: Date, @@ -55,21 +63,6 @@ export function getClassroomSupportDailyStrategy( return strategies[dayIndex % strategies.length] ?? null; } -export function toggleClassroomSupportFavorite( - favoriteStrategyIds: ReadonlySet, - strategyId: string, -): ReadonlySet { - const next = new Set(favoriteStrategyIds); - - if (next.has(strategyId)) { - next.delete(strategyId); - } else { - next.add(strategyId); - } - - return next; -} - export function toClassroomSupportCategoryFilter(value: string): ClassroomSupportCategoryFilter { if ( value === 'all' diff --git a/frontend/src/business/classroom-support/types.ts b/frontend/src/business/classroom-support/types.ts index a220a48..58d5e58 100644 --- a/frontend/src/business/classroom-support/types.ts +++ b/frontend/src/business/classroom-support/types.ts @@ -18,6 +18,9 @@ export interface ClassroomSupportPage { readonly filteredStrategies: readonly Strategy[]; readonly favoriteStrategyIds: ReadonlySet; readonly favoriteCount: number; + readonly canPersistFavorites: boolean; + readonly isSavingFavorite: boolean; + readonly favoriteError: unknown; readonly filters: ClassroomSupportFilters; readonly selectedStrategy: Strategy | null; readonly tryTodayStrategy: Strategy | null; diff --git a/frontend/src/business/classroom-timer/hooks.ts b/frontend/src/business/classroom-timer/hooks.ts index 288f325..37818d5 100644 --- a/frontend/src/business/classroom-timer/hooks.ts +++ b/frontend/src/business/classroom-timer/hooks.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { playBuiltInSound } from '@/business/classroom-timer/audio'; import { playRecipe } from '@/business/classroom-timer/audio-recipe'; import { @@ -8,8 +7,7 @@ import { useDeleteAudioFile, useGenerateAudioFile, } from '@/business/audio-files/hooks'; -import { canManageAudioFiles } from '@/business/audio-files/selectors'; -import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; +import { usePermissions } from '@/shared/app/usePermissions'; import { createTimerParticles, formatTimerTime, @@ -26,23 +24,24 @@ import { TIMER_FINISH_REPEAT_DELAY_MS, TIMER_PREVIEW_DURATION_MS, } from '@/shared/constants/classroomTimer'; -import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { + CLASSROOM_TIMER_BACKGROUNDS, + CLASSROOM_TIMER_PRESETS, + CLASSROOM_TIMER_SOUNDS, + CLASSROOM_TIMER_TIPS, +} from '@/shared/constants/classroomTimerContent'; import { TIMER_SOUND_GROUP_LABELS, TIMER_GENERATED_SOUND_ICON, TIMER_UPLOADED_SOUND_ICON, } from '@/shared/constants/classroomTimer'; import type { - ClassroomTimerTip, - PresetTimerOption, SensoryBackground, SensoryBackgroundId, TimerSound, TimerSoundGroup, - TimerSoundOption, } from '@/shared/types/classroomTimer'; import type { AudioFileDto } from '@/shared/types/audioFiles'; -import type { UserRole } from '@/shared/types/app'; declare global { interface Window { @@ -50,27 +49,12 @@ declare global { } } -export function useClassroomTimer(userRole: UserRole) { - const backgroundsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.classroomTimerBackgrounds, - [], - ); - const soundsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.classroomTimerSounds, - [], - ); +export function useClassroomTimer() { + const permissions = usePermissions(); const audioFilesQuery = useAudioFiles(); const generateMutation = useGenerateAudioFile(); const deleteMutation = useDeleteAudioFile(); - const canManageAudio = canManageAudioFiles(userRole); - const presetsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.classroomTimerPresets, - [], - ); - const tipsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.classroomTimerTips, - [], - ); + const canManageAudio = permissions.has('MANAGE_AUDIO_FILES'); const [totalSeconds, setTotalSeconds] = useState(DEFAULT_TIMER_SECONDS); const [remainingSeconds, setRemainingSeconds] = useState(DEFAULT_TIMER_SECONDS); const [isRunning, setIsRunning] = useState(false); @@ -95,11 +79,10 @@ export function useClassroomTimer(userRole: UserRole) { () => createTimerParticles(FULLSCREEN_PARTICLE_COUNT, 8, 4), [], ); - const backgrounds = backgroundsQuery.payload; - const builtInOptions = soundsQuery.payload; - const presets = presetsQuery.payload; - const tips = tipsQuery.payload; - const contentQueries = [backgroundsQuery, soundsQuery, presetsQuery, tipsQuery]; + const backgrounds = CLASSROOM_TIMER_BACKGROUNDS; + const builtInOptions = CLASSROOM_TIMER_SOUNDS; + const presets = CLASSROOM_TIMER_PRESETS; + const tips = CLASSROOM_TIMER_TIPS; const selectedBackground = useMemo( () => backgrounds.find((background) => background.id === selectedBackgroundId) ?? backgrounds[0] ?? null, [backgrounds, selectedBackgroundId], @@ -353,8 +336,8 @@ export function useClassroomTimer(userRole: UserRole) { urgencyColor, displayParticles, fullscreenParticles, - isContentLoading: isAnyLoading(...contentQueries), - contentError: getFirstQueryError(...contentQueries), + isContentLoading: false, + contentError: null, }, refs: { fullscreenRef, diff --git a/frontend/src/business/communications/hooks.ts b/frontend/src/business/communications/hooks.ts index 007eb1b..2fd0349 100644 --- a/frontend/src/business/communications/hooks.ts +++ b/frontend/src/business/communications/hooks.ts @@ -1,34 +1,21 @@ import { useQuery } from '@tanstack/react-query'; import { + cancelCommunicationEvent, createCommunicationEvent, - createParentMessage, + deleteCommunicationEvent, listCommunicationEvents, - listParentMessages, + updateCommunicationEvent, } from '@/shared/api/communications'; import { COMMUNICATION_QUERY_KEYS } from '@/shared/constants/communications'; import type { + CommunicationEventCancelDto, CommunicationEventCreateDto, + CommunicationEventUpdateDto, CommunicationEventType, - ParentMessageCategory, - ParentMessageCreateDto, } from '@/shared/types/communications'; import { getApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; -export function useParentMessages(category?: ParentMessageCategory) { - return useQuery({ - queryKey: category ? [...COMMUNICATION_QUERY_KEYS.parentMessages, category] : COMMUNICATION_QUERY_KEYS.parentMessages, - queryFn: () => getApiListRows(listParentMessages(category)), - }); -} - -export function useSendParentMessage() { - return useInvalidatingMutation({ - mutationFn: (request: ParentMessageCreateDto) => createParentMessage(request), - invalidateQueryKey: COMMUNICATION_QUERY_KEYS.parentMessages, - }); -} - export function useCommunicationEvents(type?: CommunicationEventType) { return useQuery({ queryKey: type ? [...COMMUNICATION_QUERY_KEYS.events, type] : COMMUNICATION_QUERY_KEYS.events, @@ -42,3 +29,24 @@ export function useCreateCommunicationEvent() { invalidateQueryKey: COMMUNICATION_QUERY_KEYS.events, }); } + +export function useUpdateCommunicationEvent() { + return useInvalidatingMutation({ + mutationFn: (request: CommunicationEventUpdateDto) => updateCommunicationEvent(request), + invalidateQueryKey: COMMUNICATION_QUERY_KEYS.events, + }); +} + +export function useDeleteCommunicationEvent() { + return useInvalidatingMutation({ + mutationFn: (id: string) => deleteCommunicationEvent(id), + invalidateQueryKey: COMMUNICATION_QUERY_KEYS.events, + }); +} + +export function useCancelCommunicationEvent() { + return useInvalidatingMutation({ + mutationFn: (request: CommunicationEventCancelDto) => cancelCommunicationEvent(request), + invalidateQueryKey: COMMUNICATION_QUERY_KEYS.events, + }); +} diff --git a/frontend/src/business/communications/selectors.test.ts b/frontend/src/business/communications/selectors.test.ts index f0a5f4d..8dc0fbf 100644 --- a/frontend/src/business/communications/selectors.test.ts +++ b/frontend/src/business/communications/selectors.test.ts @@ -1,62 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - canCreateCommunicationEvents, - filterCommunicationEventsByRole, - getTemplateCategory, toCommunicationEventType, } from '@/business/communications/selectors'; -import type { CommunicationEventDto, ParentTemplate } from '@/shared/types/communications'; - -function createEvent( - overrides: Partial = {}, -): CommunicationEventDto { - return { - id: 'event-1', - title: 'Safety drill', - date: '2026-06-08', - type: 'drill', - roles: ['director', 'teacher'], - organizationId: 'org-1', - campusId: 'campus-1', - createdById: 'user-1', - updatedById: null, - createdAt: '2026-06-08T10:00:00.000Z', - updatedAt: '2026-06-08T10:00:00.000Z', - ...overrides, - }; -} describe('communication selectors', () => { - it('allows only director and superintendent roles to create events', () => { - expect(canCreateCommunicationEvents('director')).toBe(true); - expect(canCreateCommunicationEvents('superintendent')).toBe(true); - expect(canCreateCommunicationEvents('teacher')).toBe(false); - expect(canCreateCommunicationEvents('support_staff')).toBe(false); - expect(canCreateCommunicationEvents('office_manager')).toBe(false); - }); - - it('resolves selected template category and falls back to general', () => { - const templates: readonly ParentTemplate[] = [ - { id: 'progress', template: 'Progress note', category: 'progress' }, - { id: 'event', template: 'Event note', category: 'event' }, - ]; - - expect(getTemplateCategory('progress', templates)).toBe('progress'); - expect(getTemplateCategory('missing', templates)).toBe('general'); - expect(getTemplateCategory(null, templates)).toBe('general'); - }); - - it('filters communication events by visible user role', () => { - const events = [ - createEvent({ id: 'teacher-event', roles: ['teacher'] }), - createEvent({ id: 'director-event', roles: ['director', 'superintendent'] }), - createEvent({ id: 'office-event', roles: ['office_manager'] }), - ]; - - expect(filterCommunicationEventsByRole(events, 'director')).toEqual([events[1]]); - expect(filterCommunicationEventsByRole(events, 'office_manager')).toEqual([events[2]]); - }); - it('normalizes unsupported event types to meeting', () => { expect(toCommunicationEventType('deadline')).toBe('deadline'); expect(toCommunicationEventType('unsupported')).toBe('meeting'); diff --git a/frontend/src/business/communications/selectors.ts b/frontend/src/business/communications/selectors.ts index 2023f14..0deb5d7 100644 --- a/frontend/src/business/communications/selectors.ts +++ b/frontend/src/business/communications/selectors.ts @@ -1,30 +1,8 @@ import type { CommunicationEventType, - CommunicationEventDto, - ParentMessageCategory, } from '@/shared/types/communications'; -import type { UserRole } from '@/shared/types/app'; import { COMMUNICATION_EVENT_TYPES } from '@/shared/constants/communications'; -export function canCreateCommunicationEvents(userRole: UserRole): boolean { - return userRole === 'director' || userRole === 'superintendent'; -} - -export function getTemplateCategory( - selectedTemplateId: string | null, - templates: readonly { readonly id: string; readonly category: ParentMessageCategory }[], -): ParentMessageCategory { - const template = templates.find((item) => item.id === selectedTemplateId); - return template?.category ?? 'general'; -} - -export function filterCommunicationEventsByRole( - events: readonly CommunicationEventDto[], - userRole: UserRole, -): readonly CommunicationEventDto[] { - return events.filter((event) => event.roles.includes(userRole)); -} - export function toCommunicationEventType(value: string): CommunicationEventType { return COMMUNICATION_EVENT_TYPES.find((type) => type === value) ?? 'meeting'; } diff --git a/frontend/src/business/dashboard/hooks.ts b/frontend/src/business/dashboard/hooks.ts index 448b80d..c08a2b4 100644 --- a/frontend/src/business/dashboard/hooks.ts +++ b/frontend/src/business/dashboard/hooks.ts @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react'; import { useCommunicationEvents } from '@/business/communications/hooks'; -import { filterCommunicationEventsByRole } from '@/business/communications/selectors'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { getDashboardGreeting, @@ -15,8 +14,12 @@ import type { DashboardProps, } from '@/business/dashboard/types'; import { useFrameEntries } from '@/business/frame/hooks'; +import { getScopedModules } from '@/business/app-shell/selectors'; import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; +import { useScopeContext } from '@/shared/app/scope-context'; +import { useAuth } from '@/shared/app/useAuth'; +import { MODULES } from '@/shared/constants/appData'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; @@ -34,6 +37,8 @@ export function useDashboardPage({ zoneCheckIn, setZoneCheckIn, }: DashboardProps): DashboardPage { + const { user } = useAuth(); + const { tier, selectedTenant } = useScopeContext(); const [dashboardDate] = useState(() => new Date()); const quotesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.dashboardEncouragingQuotes, @@ -48,19 +53,16 @@ export function useDashboardPage({ null, ); const frameEntriesQuery = useFrameEntries(); - const zoneCheckInState = useTodayZoneCheckIn(); + const canUseZoneCheckIn = canZoneCheckIn(user); + const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const communicationEventsQuery = useCommunicationEvents(); - const roleEvents = useMemo( - () => filterCommunicationEventsByRole(communicationEventsQuery.data ?? [], userRole), - [communicationEventsQuery.data, userRole], - ); const upcomingEvents = useMemo( - () => selectDashboardUpcomingEvents(roleEvents), - [roleEvents], + () => selectDashboardUpcomingEvents(communicationEventsQuery.data ?? []), + [communicationEventsQuery.data], ); const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.todayZone); const needsZoneCheckIn = shouldNudgeZoneCheckIn( - userRole, + user, zoneCheckInState.isLoading, zoneCheckInState.isCheckedInToday, ); @@ -68,6 +70,14 @@ export function useDashboardPage({ () => selectDashboardQuote(quotesQuery.payload, dashboardDate), [dashboardDate, quotesQuery.payload], ); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const scopedModuleIds = useMemo( + () => new Set( + getScopedModules(MODULES, user, effectiveTier, selectedTenant !== null) + .map((module) => module.id), + ), + [effectiveTier, selectedTenant, user], + ); async function checkInZone(zone: ZoneColor) { await zoneCheckInState.setZone(zone); @@ -90,7 +100,7 @@ export function useDashboardPage({ isError: Boolean(quotesQuery.error), }, zoneOptions: DASHBOARD_ZONE_OPTIONS, - showZoneCheckIn: canZoneCheckIn(userRole), + showZoneCheckIn: canUseZoneCheckIn, activeZone, needsZoneCheckIn, isZoneSaving: zoneCheckInState.isSaving, @@ -110,7 +120,7 @@ export function useDashboardPage({ isLoading: signOfWeekQuery.isLoading, isError: Boolean(signOfWeekQuery.error), }, - quickActions: selectDashboardQuickActions(userRole), + quickActions: selectDashboardQuickActions(scopedModuleIds), goToModule: setCurrentModule, checkInZone, resetZone, diff --git a/frontend/src/business/dashboard/selectors.test.ts b/frontend/src/business/dashboard/selectors.test.ts index 7ac3d1b..30cfa68 100644 --- a/frontend/src/business/dashboard/selectors.test.ts +++ b/frontend/src/business/dashboard/selectors.test.ts @@ -23,9 +23,13 @@ function createEvent(id: string): CommunicationEventDto { title: `Event ${id}`, date: '2026-06-09T10:00:00.000Z', type: 'meeting', + targetLevel: 'campus', roles: ['teacher'], organizationId: 'org-1', campusId: 'campus-1', + schoolId: null, + classId: null, + canceledEventId: null, createdById: 'user-1', updatedById: null, createdAt: '2026-06-09T10:00:00.000Z', @@ -72,8 +76,13 @@ describe('dashboard selectors', () => { ])).toHaveLength(4); }); - it('hides classroom quick action for office role', () => { - expect(selectDashboardQuickActions('office_manager').some((action) => action.module === 'classroom')).toBe(false); - expect(selectDashboardQuickActions('teacher').some((action) => action.module === 'classroom')).toBe(true); + it('filters quick actions by scoped accessible modules', () => { + const officeActions = selectDashboardQuickActions(new Set(['frame', 'attendance', 'handbook', 'safety'])); + expect(officeActions.some((action) => action.module === 'classroom')).toBe(false); + expect(officeActions.some((action) => action.module === 'handbook')).toBe(true); + + const teacherActions = selectDashboardQuickActions(new Set(['classroom', 'timer', 'qbs', 'zones'])); + expect(teacherActions.some((action) => action.module === 'classroom')).toBe(true); + expect(teacherActions.some((action) => action.module === 'handbook')).toBe(false); }); }); diff --git a/frontend/src/business/dashboard/selectors.ts b/frontend/src/business/dashboard/selectors.ts index a700687..8e8f2ce 100644 --- a/frontend/src/business/dashboard/selectors.ts +++ b/frontend/src/business/dashboard/selectors.ts @@ -4,7 +4,7 @@ import { } from '@/shared/constants/dashboard'; import type { DashboardQuickAction } from '@/shared/constants/dashboard'; import type { - UserRole, + ModuleId, ZoneColor, } from '@/shared/types/app'; import type { CommunicationEventDto } from '@/shared/types/communications'; @@ -62,6 +62,8 @@ export function selectDashboardUpcomingEvents( return events.slice(0, DASHBOARD_UPCOMING_EVENTS_LIMIT); } -export function selectDashboardQuickActions(userRole: UserRole): readonly DashboardQuickAction[] { - return DASHBOARD_QUICK_ACTIONS.filter((action) => !action.hiddenForRoles?.includes(userRole)); +export function selectDashboardQuickActions( + accessibleModuleIds: ReadonlySet, +): readonly DashboardQuickAction[] { + return DASHBOARD_QUICK_ACTIONS.filter((action) => accessibleModuleIds.has(action.module)); } diff --git a/frontend/src/business/direct-messages/api.ts b/frontend/src/business/direct-messages/api.ts new file mode 100644 index 0000000..a85cd50 --- /dev/null +++ b/frontend/src/business/direct-messages/api.ts @@ -0,0 +1,8 @@ +export { + getDirectContacts, + getDirectConversations, + getDirectThread, + sendDirectMessage, + type DirectContact, + type DirectConversation, +} from '@/shared/api/directMessages'; diff --git a/frontend/src/business/director-dashboard/hooks.ts b/frontend/src/business/director-dashboard/hooks.ts index d8db9c0..d2a8d0e 100644 --- a/frontend/src/business/director-dashboard/hooks.ts +++ b/frontend/src/business/director-dashboard/hooks.ts @@ -6,6 +6,7 @@ import { useStaffAttendanceRecords, useStaffAttendanceSummary, } from '@/business/staff-attendance/hooks'; +import { usePolicyAcknowledgmentReport } from '@/business/policies/hooks'; import { buildDirectorFramePreviews, buildDirectorOverviewCards, @@ -16,13 +17,22 @@ import { DIRECTOR_DASHBOARD_QUICK_ACTIONS, type DirectorDashboardTimeRange, } from '@/shared/constants/directorDashboard'; +import { useAuth } from '@/shared/app/useAuth'; +import { getLeadershipDashboardName } from '@/business/app-shell/selectors'; +import { getActiveTenant } from '@/business/scope/selectors'; +import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; export function useDirectorDashboardPage(): DirectorDashboardPage { + const { user, profile } = useAuth(); + const role = profile?.role ?? DEFAULT_PRODUCT_ROLE; + const title = getLeadershipDashboardName(role); + const scopeLabel = getActiveTenant(user)?.name ?? ''; const [timeRange, setTimeRangeState] = useState('month'); const frameEntriesQuery = useFrameEntries(); const quizResultsQuery = useSafetyQuizResults(); const staffAttendanceRecordsQuery = useStaffAttendanceRecords(); const staffAttendanceSummaryQuery = useStaffAttendanceSummary(); + const acknowledgmentReportQuery = usePolicyAcknowledgmentReport(); const frameEntries = frameEntriesQuery.data ?? []; const quizResults = quizResultsQuery.data ?? []; const attendanceRecords = staffAttendanceRecordsQuery.data ?? []; @@ -30,19 +40,24 @@ export function useDirectorDashboardPage(): DirectorDashboardPage { const isLoading = frameEntriesQuery.isLoading || quizResultsQuery.isLoading || staffAttendanceRecordsQuery.isLoading - || staffAttendanceSummaryQuery.isLoading; + || staffAttendanceSummaryQuery.isLoading + || acknowledgmentReportQuery.isLoading; const error = frameEntriesQuery.error ?? quizResultsQuery.error ?? staffAttendanceRecordsQuery.error - ?? staffAttendanceSummaryQuery.error; + ?? staffAttendanceSummaryQuery.error + ?? acknowledgmentReportQuery.error; return { + title, + scopeLabel, timeRange, overviewCards: buildDirectorOverviewCards( attendanceRecords, quizResults, frameEntries, staffCount, + acknowledgmentReportQuery.data?.summary, ), riskAreas: buildDirectorRiskAreas(attendanceRecords, quizResults, staffCount), framePreviews: buildDirectorFramePreviews(frameEntries), diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index 49f7a51..84fbff5 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -77,10 +77,25 @@ describe('director dashboard selectors', () => { [createQuizResult()], [createFrameEntry()], 2, + { + scope: 'campus', + totalDocuments: 2, + totalStaff: 2, + totalRequired: 4, + acknowledgedCount: 3, + missingCount: 1, + completionRate: 75, + }, ); - expect(cards.map((card) => card.value)).toEqual(['50%', '1/2', '1', '2']); - expect(cards.map((card) => card.module)).toEqual(['attendance', 'qbs', 'frame', 'attendance']); + expect(cards.map((card) => card.value)).toEqual(['50%', '1/2', '1', '2', '75%']); + expect(cards.map((card) => card.module)).toEqual([ + 'attendance', + 'qbs', + 'frame', + 'attendance', + 'acknowledgments', + ]); }); it('flags dashboard risk levels from quiz completion and absences', () => { diff --git a/frontend/src/business/director-dashboard/selectors.ts b/frontend/src/business/director-dashboard/selectors.ts index 4742804..e2d9b0e 100644 --- a/frontend/src/business/director-dashboard/selectors.ts +++ b/frontend/src/business/director-dashboard/selectors.ts @@ -12,6 +12,7 @@ import { import type { FrameEntryViewModel } from '@/business/frame/types'; import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types'; import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; +import type { PolicyAcknowledgmentReportSummaryDto } from '@/shared/types/policyDocuments'; import type { DirectorFramePreview, DirectorOverviewCard, @@ -34,9 +35,11 @@ export function buildDirectorOverviewCards( quizResults: readonly SafetyQuizResultDto[], frameEntries: readonly FrameEntryViewModel[], staffCount: number, + acknowledgmentSummary?: PolicyAcknowledgmentReportSummaryDto | null, ): readonly DirectorOverviewCard[] { const attendanceRate = staffAttendanceRate(attendanceRecords); const quizCompletionRate = calculateQuizCompletionRate(quizResults, staffCount); + const acknowledgmentRate = acknowledgmentSummary?.completionRate ?? 0; return [ { @@ -75,6 +78,15 @@ export function buildDirectorOverviewCards( tone: 'purple', module: 'attendance', }, + { + label: 'Acknowledgments', + value: `${acknowledgmentRate}%`, + change: `${acknowledgmentSummary?.missingCount ?? 0} missing`, + trend: acknowledgmentRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down', + iconId: 'clipboard', + tone: 'emerald', + module: 'acknowledgments', + }, ]; } diff --git a/frontend/src/business/director-dashboard/types.ts b/frontend/src/business/director-dashboard/types.ts index 08bb3fc..9702e30 100644 --- a/frontend/src/business/director-dashboard/types.ts +++ b/frontend/src/business/director-dashboard/types.ts @@ -7,8 +7,8 @@ import type { SafetyQuizResultDto } from '@/shared/types/safetyQuiz'; export type DirectorDashboardTrend = 'up' | 'down'; export type DirectorDashboardRiskSeverity = 'high' | 'medium' | 'low'; -export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users'; -export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple'; +export type DirectorOverviewIconId = 'clock' | 'shield' | 'eye' | 'users' | 'clipboard'; +export type DirectorOverviewTone = 'orange' | 'blue' | 'amber' | 'purple' | 'emerald'; export type DirectorFrameSectionLetter = 'F' | 'R' | 'A' | 'M' | 'E'; export interface DirectorOverviewCard { @@ -39,6 +39,10 @@ export interface DirectorFramePreview { } export interface DirectorDashboardPage { + /** Role-specific dashboard title (e.g. "Owner Dashboard"). */ + readonly title: string; + /** Name of the tenant whose data is shown (org/school/campus). */ + readonly scopeLabel: string; readonly timeRange: DirectorDashboardTimeRange; readonly overviewCards: readonly DirectorOverviewCard[]; readonly riskAreas: readonly DirectorRiskArea[]; diff --git a/frontend/src/business/files/api.ts b/frontend/src/business/files/api.ts new file mode 100644 index 0000000..c6058bb --- /dev/null +++ b/frontend/src/business/files/api.ts @@ -0,0 +1 @@ +export { fileDownloadUrl, uploadFile } from '@/shared/api/files'; diff --git a/frontend/src/business/frame/hooks.ts b/frontend/src/business/frame/hooks.ts index f767d91..97bb9d0 100644 --- a/frontend/src/business/frame/hooks.ts +++ b/frontend/src/business/frame/hooks.ts @@ -17,11 +17,10 @@ import { toFrameEntryMutationDto, toFrameEntryViewModel, } from '@/business/frame/mappers'; -import { canEditFrameEntries } from '@/business/frame/selectors'; -import { UserRole } from '@/shared/types/app'; import { mapApiListRows } from '@/shared/business/apiListRows'; import { toWeekStartIso } from '@/shared/business/week'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import { usePermissions } from '@/shared/app/usePermissions'; const EMPTY_FRAME_ENTRIES: readonly FrameEntryViewModel[] = []; @@ -64,8 +63,9 @@ export function useFrameEntries() { }); } -export function useFrameModule(userRole: UserRole, userName: string) { - const canEdit = canEditFrameEntries(userRole); +export function useFrameModule(userName: string) { + const permissions = usePermissions(); + const canEdit = permissions.has('MANAGE_FRAME'); const authorName = userName.trim(); const [expandedIdState, setExpandedIdState] = useState(null); const [isEditing, setIsEditing] = useState(false); diff --git a/frontend/src/business/frame/selectors.test.ts b/frontend/src/business/frame/selectors.test.ts deleted file mode 100644 index fccb018..0000000 --- a/frontend/src/business/frame/selectors.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { canEditFrameEntries } from '@/business/frame/selectors'; -import type { UserRole } from '@/shared/types/app'; - -describe('frame selectors', () => { - it('allows director and superintendent users to edit FRAME entries', () => { - const editorRoles: readonly UserRole[] = ['director', 'superintendent']; - - expect(editorRoles.map((role) => canEditFrameEntries(role))).toEqual([true, true]); - }); - - it('keeps staff roles read-only for FRAME entries', () => { - const readOnlyRoles: readonly UserRole[] = ['teacher', 'support_staff', 'office_manager']; - - expect(readOnlyRoles.map((role) => canEditFrameEntries(role))).toEqual([false, false, false]); - }); -}); diff --git a/frontend/src/business/frame/selectors.ts b/frontend/src/business/frame/selectors.ts deleted file mode 100644 index ef114d7..0000000 --- a/frontend/src/business/frame/selectors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { UserRole } from '@/shared/types/app'; - -export function canEditFrameEntries(userRole: UserRole): boolean { - return userRole === 'director' || userRole === 'superintendent'; -} diff --git a/frontend/src/business/iam-capabilities/hooks.ts b/frontend/src/business/iam-capabilities/hooks.ts new file mode 100644 index 0000000..f4f8251 --- /dev/null +++ b/frontend/src/business/iam-capabilities/hooks.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getIamCapabilities } from '@/shared/api/iamCapabilities'; + +export const IAM_CAPABILITIES_QUERY_KEY = ['iam-capabilities'] as const; + +export function useIamCapabilities() { + return useQuery({ + queryKey: IAM_CAPABILITIES_QUERY_KEY, + queryFn: getIamCapabilities, + }); +} diff --git a/frontend/src/business/my-class/api.ts b/frontend/src/business/my-class/api.ts new file mode 100644 index 0000000..81d44eb --- /dev/null +++ b/frontend/src/business/my-class/api.ts @@ -0,0 +1,7 @@ +export { getClass } from '@/shared/api/classes'; +export { + getClassAttendanceSummary, + upsertClassAttendance, +} from '@/shared/api/classAttendance'; +export { listGuardianStudents } from '@/shared/api/guardianStudents'; +export { listUsers, type AdminUserRow } from '@/shared/api/users'; diff --git a/frontend/src/business/personality/directoryHooks.ts b/frontend/src/business/personality/directoryHooks.ts index f075741..97220d0 100644 --- a/frontend/src/business/personality/directoryHooks.ts +++ b/frontend/src/business/personality/directoryHooks.ts @@ -1,5 +1,4 @@ import { useMemo, useState } from 'react'; -import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import type { PersonalityDirectoryFilterGroup, PersonalityDirectoryPage, @@ -9,19 +8,14 @@ import { filterPersonalityDirectoryTypes, getPersonalityDirectoryGroupDescription, } from '@/business/personality/selectors'; -import type { PersonalityType } from '@/shared/constants/personalityCatalog'; -import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { PERSONALITY_TYPES } from '@/shared/constants/personalityStaticContent'; export function usePersonalityDirectoryPage(highlightType?: string | null): PersonalityDirectoryPage { - const personalityTypesQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityTypes, - [], - ); const [expandedType, setExpandedType] = useState(highlightType || null); const [searchQuery, setSearchQuery] = useState(''); const [filterGroup, setFilterGroup] = useState('all'); const [expandedSections, setExpandedSections] = useState>>({}); - const personalityTypes = personalityTypesQuery.payload; + const personalityTypes = PERSONALITY_TYPES; const filteredTypes = useMemo( () => filterPersonalityDirectoryTypes(personalityTypes, searchQuery, filterGroup), [filterGroup, personalityTypes, searchQuery], @@ -54,8 +48,8 @@ export function usePersonalityDirectoryPage(highlightType?: string | null): Pers searchQuery, filterGroup, expandedSections, - isLoading: personalityTypesQuery.isLoading, - error: personalityTypesQuery.error, + isLoading: false, + error: null, groupDescription: getPersonalityDirectoryGroupDescription(filterGroup), setSearchQuery, setFilterGroup, diff --git a/frontend/src/business/personality/emotionalIntelligenceHooks.ts b/frontend/src/business/personality/emotionalIntelligenceHooks.ts index f13c1fd..cdc1de7 100644 --- a/frontend/src/business/personality/emotionalIntelligenceHooks.ts +++ b/frontend/src/business/personality/emotionalIntelligenceHooks.ts @@ -11,24 +11,30 @@ import { groupPersonalityDistribution, totalPersonalityDistribution, } from '@/business/personality/selectors'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { + PERSONALITY_TYPES, + PERSONALITY_WORKPLACE_CONTENT, +} from '@/shared/constants/personalityStaticContent'; import type { UserRole } from '@/shared/types/app'; import type { EmotionalIntelligenceQuestion, EmotionalIntelligenceTab, EmotionalIntelligenceTopic, EmotionalIntelligenceWeeklyFocus, - PersonalityWorkplaceContent, TeamWellnessMetric, } from '@/shared/types/emotionalIntelligence'; import type { PersonalityDistributionDto } from '@/shared/types/personality'; -import type { PersonalityType } from '@/shared/constants/personalityCatalog'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; +import { useScopeContext } from '@/shared/app/scope-context'; const EMPTY_PERSONALITY_DISTRIBUTION: readonly PersonalityDistributionDto[] = []; export function useEmotionalIntelligencePage(userRole: UserRole) { + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistPersonalResults = canPersistPersonalScopeResults(ownTenant, selectedTenant); const assessmentQuestionsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.emotionalIntelligenceAssessmentQuestions, [], @@ -49,21 +55,13 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { CONTENT_CATALOG_TYPES.emotionalIntelligenceWeeklyFocus, null, ); - const personalityWorkplaceContentQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityWorkplaceContent, - null, - ); - const personalityTypesQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityTypes, - [], - ); const [activeTab, setActiveTab] = useState('assessment'); const [assessmentStarted, setAssessmentStarted] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [answers, setAnswers] = useState([]); const [assessmentComplete, setAssessmentComplete] = useState(false); - const currentPersonalityQuery = useCurrentPersonalityResult(); + const currentPersonalityQuery = useCurrentPersonalityResult(canPersistPersonalResults); const savePersonalityMutation = useSaveCurrentPersonalityResult(); const canViewPersonalityDistribution = userRole === 'director' || userRole === 'superintendent'; const personalityDistributionQuery = usePersonalityDistribution(undefined, canViewPersonalityDistribution); @@ -91,8 +89,6 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { growthTipsQuery, teamWellnessMetricsQuery, weeklyFocusQuery, - personalityWorkplaceContentQuery, - personalityTypesQuery, ]; const handleAnswer = (scoreIndex: number) => { @@ -119,6 +115,10 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { }; const handlePersonalityResult = async (code: string, quizAnswers: Record) => { + if (!canPersistPersonalResults) { + return; + } + await savePersonalityMutation.mutateAsync({ personalityType: code, quizAnswers, @@ -143,8 +143,9 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { personalityResult, savedAnswers, savedDate, - isSaving: savePersonalityMutation.isPending, - isLoadingSaved: currentPersonalityQuery.isLoading, + canPersistPersonalResults, + isSaving: canPersistPersonalResults && savePersonalityMutation.isPending, + isLoadingSaved: canPersistPersonalResults && currentPersonalityQuery.isLoading, distribution, distributionLoading: personalityDistributionQuery.isLoading || personalityDistributionQuery.isFetching, distributionTotal, @@ -157,8 +158,8 @@ export function useEmotionalIntelligencePage(userRole: UserRole) { growthTips: growthTipsQuery.payload, teamWellnessMetrics: teamWellnessMetricsQuery.payload, weeklyFocus: weeklyFocusQuery.payload, - personalityWorkplaceContent: personalityWorkplaceContentQuery.payload, - personalityTypes: personalityTypesQuery.payload, + personalityWorkplaceContent: PERSONALITY_WORKPLACE_CONTENT, + personalityTypes: PERSONALITY_TYPES, contentLoading: isAnyLoading(...contentQueries), contentError: getFirstQueryError(...contentQueries), }, diff --git a/frontend/src/business/personality/queryHooks.ts b/frontend/src/business/personality/queryHooks.ts index 70ad705..d56632f 100644 --- a/frontend/src/business/personality/queryHooks.ts +++ b/frontend/src/business/personality/queryHooks.ts @@ -13,9 +13,10 @@ import { PERSONALITY_QUERY_KEYS } from '@/shared/constants/personality'; import { getApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; -export function useCurrentPersonalityResult() { +export function useCurrentPersonalityResult(enabled = true) { return useQuery({ queryKey: PERSONALITY_QUERY_KEYS.current, + enabled, queryFn: async () => { const response = await getCurrentPersonalityResult(); return toPersonalityQuizResultViewModel(response); diff --git a/frontend/src/business/personality/quizWorkflowHooks.ts b/frontend/src/business/personality/quizWorkflowHooks.ts index ec24adc..7039f44 100644 --- a/frontend/src/business/personality/quizWorkflowHooks.ts +++ b/frontend/src/business/personality/quizWorkflowHooks.ts @@ -1,7 +1,5 @@ import { useMemo, useState } from 'react'; -import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import type { - PersonalityQuizFeature, PersonalityQuizResultTab, PersonalityQuizWorkflowInput, } from '@/business/personality/types'; @@ -18,27 +16,17 @@ import { calculateMBTI, getPersonalityType, } from '@/shared/constants/personalityCatalog'; -import type { PersonalityType, QuizQuestion } from '@/shared/constants/personalityCatalog'; -import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; -import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; +import { + PERSONALITY_QUIZ_FEATURES, + PERSONALITY_QUIZ_QUESTIONS, + PERSONALITY_TYPES, +} from '@/shared/constants/personalityStaticContent'; export function usePersonalityQuizWorkflow({ onResult, savedType, savedAnswers, }: PersonalityQuizWorkflowInput) { - const questionsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityQuizQuestions, - [], - ); - const personalityTypesQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityTypes, - [], - ); - const featuresQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.personalityQuizFeatures, - [], - ); const [started, setStarted] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [answers, setAnswers] = useState>({}); @@ -48,8 +36,8 @@ export function usePersonalityQuizWorkflow({ const [activeResultTab, setActiveResultTab] = useState('overview'); const [showSavedResult, setShowSavedResult] = useState(false); - const questions = questionsQuery.payload; - const personalityTypes = personalityTypesQuery.payload; + const questions = PERSONALITY_QUIZ_QUESTIONS; + const personalityTypes = PERSONALITY_TYPES; const totalQuestions = questions.length; const currentQuestion = questions[currentQuestionIndex]; const savedResult = savedType ? getPersonalityType(savedType, personalityTypes) ?? null : null; @@ -62,7 +50,7 @@ export function usePersonalityQuizWorkflow({ const effectiveShowResult = showResult || (!started && Boolean(savedResult)); const result = effectiveResultCode ? getPersonalityType(effectiveResultCode, personalityTypes) ?? null : null; const progress = totalQuestions > 0 ? calculatePersonalityQuizProgress(currentQuestionIndex, totalQuestions) : 0; - const features = featuresQuery.payload; + const features = PERSONALITY_QUIZ_FEATURES; const dimensionProgress = useMemo( () => currentQuestion ? getPersonalityDimensionProgress(currentQuestion, effectiveAnswers, questions) : [], [effectiveAnswers, currentQuestion, questions], @@ -80,7 +68,6 @@ export function usePersonalityQuizWorkflow({ [result], ); const communicationGrowthArea = result ? getPersonalityCommunicationGrowthArea(result) : ''; - const contentQueries = [questionsQuery, personalityTypesQuery, featuresQuery]; const handleSelectAnswer = (value: string) => { setSelectedAnswer(value); @@ -141,8 +128,8 @@ export function usePersonalityQuizWorkflow({ personalityTypes, totalQuestions, currentQuestion, - isContentLoading: isAnyLoading(...contentQueries), - contentError: getFirstQueryError(...contentQueries), + isContentLoading: false, + contentError: null, progress, features, dimensionProgress, diff --git a/frontend/src/business/platform/hooks.ts b/frontend/src/business/platform/hooks.ts new file mode 100644 index 0000000..dfa05ff --- /dev/null +++ b/frontend/src/business/platform/hooks.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPlatformStats } from '@/shared/api/platform'; + +export const PLATFORM_STATS_QUERY_KEY = ['platform', 'stats'] as const; + +export function usePlatformStats() { + return useQuery({ + queryKey: PLATFORM_STATS_QUERY_KEY, + queryFn: getPlatformStats, + }); +} diff --git a/frontend/src/business/policies/hooks.ts b/frontend/src/business/policies/hooks.ts index 236eba9..1aaf10c 100644 --- a/frontend/src/business/policies/hooks.ts +++ b/frontend/src/business/policies/hooks.ts @@ -7,6 +7,7 @@ import { } from '@/shared/api/policyDocuments'; import { acknowledgePolicyDocument, + getPolicyAcknowledgmentReport, listMyPolicyAcknowledgments, } from '@/shared/api/policyAcknowledgments'; import { @@ -23,9 +24,10 @@ interface PolicyUpdateInput { readonly policy: PolicyFormInput; } -export function usePolicies() { +export function usePolicies(enabled = true) { return useQuery({ queryKey: POLICY_QUERY_KEYS.documents, + enabled, queryFn: () => mapApiListRows( listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies), @@ -58,9 +60,10 @@ export function useDeletePolicy() { } /** The current user's persisted policy acknowledgments (replaces local state). */ -export function usePolicyAcknowledgments() { +export function usePolicyAcknowledgments(enabled = true) { return useQuery({ queryKey: POLICY_QUERY_KEYS.acknowledgments, + enabled, queryFn: () => getApiListRows(listMyPolicyAcknowledgments()), }); } @@ -72,3 +75,10 @@ export function useAcknowledgePolicy() { invalidateQueryKey: POLICY_QUERY_KEYS.acknowledgments, }); } + +export function usePolicyAcknowledgmentReport() { + return useQuery({ + queryKey: POLICY_QUERY_KEYS.acknowledgmentReport, + queryFn: getPolicyAcknowledgmentReport, + }); +} diff --git a/frontend/src/business/policies/mappers.test.ts b/frontend/src/business/policies/mappers.test.ts index 6e8fe14..ad08fde 100644 --- a/frontend/src/business/policies/mappers.test.ts +++ b/frontend/src/business/policies/mappers.test.ts @@ -39,6 +39,7 @@ describe('policy mappers', () => { title: 'Incident Response', category: 'Safety', content: 'Use the approved incident response process.', + version: 2, lastUpdated: '2026-06-08', updatedBy: 'Dr. Williams', }); @@ -54,6 +55,7 @@ describe('policy mappers', () => { title: '', category: POLICY_DEFAULT_CATEGORY, content: '', + version: 2, lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL, updatedBy: POLICY_UPDATED_BY_LABEL, }); diff --git a/frontend/src/business/policies/mappers.ts b/frontend/src/business/policies/mappers.ts index dd4df15..92b20b8 100644 --- a/frontend/src/business/policies/mappers.ts +++ b/frontend/src/business/policies/mappers.ts @@ -37,6 +37,7 @@ export function toPolicyViewModel(dto: PolicyDocumentDto): PolicyViewModel { title: dto.title || '', category: toPolicyCategory(dto.tag), content: dto.body || '', + version: dto.version, lastUpdated: toDateOnly(dto.updatedAt), updatedBy: dto.author || POLICY_UPDATED_BY_LABEL, }; diff --git a/frontend/src/business/policies/pageHooks.ts b/frontend/src/business/policies/pageHooks.ts index 8b5b447..0188936 100644 --- a/frontend/src/business/policies/pageHooks.ts +++ b/frontend/src/business/policies/pageHooks.ts @@ -9,17 +9,21 @@ import { useUpdatePolicy, } from '@/business/policies/hooks'; import { - canManagePolicies, filterPolicies, getPolicyCategoryFilters, + isPolicyDocumentAcknowledged, isPolicyFormValid, } from '@/business/policies/selectors'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import type { PolicyCategory, PolicyFormInput } from '@/business/policies/types'; import { POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies'; -import type { UserRole } from '@/shared/types/app'; +import { usePermissions } from '@/shared/app/usePermissions'; +import { useScopeContext } from '@/shared/app/scope-context'; export interface PoliciesPageState { readonly canManage: boolean; + readonly canAcknowledge: boolean; + readonly canPersistAcknowledgments: boolean; readonly filteredPolicies: NonNullable['data']>; readonly categories: readonly (PolicyCategory | 'all')[]; readonly searchQuery: string; @@ -68,8 +72,16 @@ const emptyPolicyDraft: PolicyFormInput = { content: '', }; -export function usePoliciesPage(userRole: UserRole): PoliciesPage { - const canManage = canManagePolicies(userRole); +export function usePoliciesPage(): PoliciesPage { + const permissions = usePermissions(); + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistAcknowledgments = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const canManage = permissions.hasAny([ + 'CREATE_POLICY_DOCUMENTS', + 'UPDATE_POLICY_DOCUMENTS', + 'DELETE_POLICY_DOCUMENTS', + ]); + const canAcknowledge = canPersistAcknowledgments && permissions.has('ACK_POLICY'); const policiesQuery = usePolicies(); const createPolicy = useCreatePolicy(); const updatePolicy = useUpdatePolicy(); @@ -82,13 +94,18 @@ export function usePoliciesPage(userRole: UserRole): PoliciesPage { const [createDraft, setCreateDraft] = useState(emptyPolicyDraft); const [editDraft, setEditDraft] = useState(emptyPolicyDraft); - const acknowledgmentsQuery = usePolicyAcknowledgments(); + const acknowledgmentsQuery = usePolicyAcknowledgments(canAcknowledge); const acknowledgePolicy = useAcknowledgePolicy(); - const acknowledgedPolicyIds: ReadonlySet = new Set( - (acknowledgmentsQuery.data ?? []).map((ack) => ack.policyDocumentId), - ); - const policies = policiesQuery.data ?? []; + const acknowledgedPolicyIds: ReadonlySet = canPersistAcknowledgments + ? new Set( + policies + .filter((policy) => + isPolicyDocumentAcknowledged(acknowledgmentsQuery.data ?? [], policy.id, policy.version), + ) + .map((policy) => policy.id), + ) + : new Set(); const filteredPolicies = filterPolicies(policies, searchQuery, categoryFilter); const isMutating = createPolicy.isPending || updatePolicy.isPending || deletePolicyMutation.isPending; const mutationError = createPolicy.error || updatePolicy.error || deletePolicyMutation.error; @@ -131,7 +148,7 @@ export function usePoliciesPage(userRole: UserRole): PoliciesPage { // Acknowledgment is a persisted, one-way action (you cannot un-acknowledge a // version); re-acknowledging is idempotent on the backend. const toggleAcknowledgement = (id: string) => { - if (!acknowledgedPolicyIds.has(id)) { + if (canAcknowledge && !acknowledgedPolicyIds.has(id)) { acknowledgePolicy.mutate(id); } }; @@ -139,6 +156,8 @@ export function usePoliciesPage(userRole: UserRole): PoliciesPage { return { state: { canManage, + canAcknowledge, + canPersistAcknowledgments, filteredPolicies, categories: getPolicyCategoryFilters(), searchQuery, diff --git a/frontend/src/business/policies/selectors.test.ts b/frontend/src/business/policies/selectors.test.ts index 2b4d0be..55eeb01 100644 --- a/frontend/src/business/policies/selectors.test.ts +++ b/frontend/src/business/policies/selectors.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - canManagePolicies, filterPolicies, getPolicyCategoryFilters, isPolicyFormValid, + isPolicyDocumentAcknowledged, toPolicyCategory, toPolicyCategoryFilter, } from '@/business/policies/selectors'; @@ -16,6 +16,7 @@ const policies: readonly PolicyViewModel[] = [ title: 'Emergency Communication', category: 'Communication', content: 'Call families after a campus-wide emergency.', + version: 1, lastUpdated: '2026-06-08', updatedBy: 'Director', }, @@ -24,6 +25,7 @@ const policies: readonly PolicyViewModel[] = [ title: 'Safety Drill', category: 'Safety', content: 'Monthly drill expectations.', + version: 1, lastUpdated: '2026-06-07', updatedBy: 'Office', }, @@ -32,22 +34,13 @@ const policies: readonly PolicyViewModel[] = [ title: 'Behavior Support', category: 'Behavior', content: 'Use approved de-escalation supports.', + version: 1, lastUpdated: '2026-06-06', updatedBy: 'Director', }, ]; describe('policy selectors', () => { - it('allows the management tier (owner/superintendent/director/office_manager) to manage policies', () => { - expect(canManagePolicies('owner')).toBe(true); - expect(canManagePolicies('superintendent')).toBe(true); - expect(canManagePolicies('director')).toBe(true); - expect(canManagePolicies('office_manager')).toBe(true); - expect(canManagePolicies('teacher')).toBe(false); - expect(canManagePolicies('support_staff')).toBe(false); - expect(canManagePolicies('student')).toBe(false); - }); - it('returns the configured category filters without creating a new source of truth', () => { expect(getPolicyCategoryFilters()).toBe(POLICY_CATEGORY_FILTERS); }); @@ -77,4 +70,21 @@ describe('policy selectors', () => { expect(isPolicyFormValid({ ...validInput, title: ' ' })).toBe(false); expect(isPolicyFormValid({ ...validInput, content: '' })).toBe(false); }); + + it('matches acknowledgments by document id and current version', () => { + const acknowledgments = [{ + id: 'ack-1', + policyDocumentId: 'policy-1', + version: 1, + userId: 'user-1', + acknowledgedAt: '2026-06-08T12:00:00Z', + organizationId: 'org-1', + campusId: null, + createdAt: '2026-06-08T12:00:00Z', + updatedAt: '2026-06-08T12:00:00Z', + }]; + + expect(isPolicyDocumentAcknowledged(acknowledgments, 'policy-1', 1)).toBe(true); + expect(isPolicyDocumentAcknowledged(acknowledgments, 'policy-1', 2)).toBe(false); + }); }); diff --git a/frontend/src/business/policies/selectors.ts b/frontend/src/business/policies/selectors.ts index 3666b72..cedff62 100644 --- a/frontend/src/business/policies/selectors.ts +++ b/frontend/src/business/policies/selectors.ts @@ -1,21 +1,6 @@ -import type { UserRole } from '@/shared/types/app'; import { POLICY_CATEGORIES, POLICY_CATEGORY_FILTERS, POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies'; import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types'; - -/** - * Roles that may author policy/handbook documents — mirrors the backend grant of - * `CREATE/UPDATE/DELETE_POLICY_DOCUMENTS` (owner/superintendent/director via full - * access; office_manager via explicit grant). The backend stays the source of - * truth; this only gates the management UI affordances. - */ -export function canManagePolicies(userRole: UserRole): boolean { - return ( - userRole === 'owner' || - userRole === 'superintendent' || - userRole === 'director' || - userRole === 'office_manager' - ); -} +import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments'; export function getPolicyCategoryFilters(): readonly (PolicyCategory | 'all')[] { return POLICY_CATEGORY_FILTERS; @@ -49,3 +34,15 @@ export function filterPolicies( export function isPolicyFormValid(input: PolicyFormInput): boolean { return Boolean(input.title.trim() && input.content.trim()); } + +export function isPolicyDocumentAcknowledged( + acknowledgments: readonly PolicyAcknowledgmentDto[], + policyDocumentId: string, + version: number, +): boolean { + return acknowledgments.some( + (acknowledgment) => + acknowledgment.policyDocumentId === policyDocumentId + && acknowledgment.version === version, + ); +} diff --git a/frontend/src/business/policies/types.ts b/frontend/src/business/policies/types.ts index ad09c20..6d040fa 100644 --- a/frontend/src/business/policies/types.ts +++ b/frontend/src/business/policies/types.ts @@ -7,6 +7,7 @@ export interface PolicyViewModel { readonly title: string; readonly category: PolicyCategory; readonly content: string; + readonly version: number; readonly lastUpdated: string; readonly updatedBy: string; } diff --git a/frontend/src/business/profile/api.ts b/frontend/src/business/profile/api.ts new file mode 100644 index 0000000..e897bf0 --- /dev/null +++ b/frontend/src/business/profile/api.ts @@ -0,0 +1,2 @@ +export { changePassword, updateOwnProfile } from '@/shared/api/auth'; +export { updateOrganization } from '@/shared/api/tenants'; diff --git a/frontend/src/business/safety-protocols/hooks.ts b/frontend/src/business/safety-protocols/hooks.ts index ad35fb9..d1de224 100644 --- a/frontend/src/business/safety-protocols/hooks.ts +++ b/frontend/src/business/safety-protocols/hooks.ts @@ -11,27 +11,28 @@ import { SAFETY_PROTOCOL_DEFAULT_TAG } from '@/shared/constants/safetyProtocols' import { mapApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; import { useAcknowledgePolicy, usePolicyAcknowledgments } from '@/business/policies/hooks'; +import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { toSafetyProtocolMutationDto, toSafetyProtocolViewModel, } from '@/business/safety-protocols/mappers'; -import { - canManageSafetyProtocols, - isSafetyDraftValid, -} from '@/business/safety-protocols/selectors'; +import { isSafetyDraftValid } from '@/business/safety-protocols/selectors'; import type { SafetyProtocolDraft, SafetyProtocolListKey, SafetyProtocolViewModel, } from '@/business/safety-protocols/types'; -import type { UserRole } from '@/shared/types/app'; +import { usePermissions } from '@/shared/app/usePermissions'; +import { useScopeContext } from '@/shared/app/scope-context'; const EMPTY_SAFETY_PROTOCOLS: readonly SafetyProtocolViewModel[] = []; /** Safety protocols = `policy_documents` of category `safety_protocol`. */ -export function useSafetyProtocols() { +export function useSafetyProtocols(enabled = true) { return useQuery({ queryKey: POLICY_QUERY_KEYS.safetyDocuments, + enabled, queryFn: () => mapApiListRows( listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.safetyProtocols), @@ -67,10 +68,17 @@ interface SafetyUpdateInput { * `steps` and `autismConsiderations` drafts are dynamic: rows can be added and * removed independently so each protocol carries its own count. */ -export function useSafetyProtocolsModule(userRole: UserRole) { - const canManage = canManageSafetyProtocols(userRole); +export function useSafetyProtocolsModule() { + const permissions = usePermissions(); + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistAcknowledgments = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const canManage = permissions.hasAny([ + 'CREATE_POLICY_DOCUMENTS', + 'UPDATE_POLICY_DOCUMENTS', + 'DELETE_POLICY_DOCUMENTS', + ]); const protocolsQuery = useSafetyProtocols(); - const acknowledgmentsQuery = usePolicyAcknowledgments(); + const acknowledgmentsQuery = usePolicyAcknowledgments(canPersistAcknowledgments); const acknowledgeProtocol = useAcknowledgePolicy(); const [expandedId, setExpandedId] = useState(null); @@ -107,9 +115,15 @@ export function useSafetyProtocolsModule(userRole: UserRole) { }); const protocols = protocolsQuery.data ?? EMPTY_SAFETY_PROTOCOLS; - const acknowledgedIds: ReadonlySet = new Set( - (acknowledgmentsQuery.data ?? []).map((ack) => ack.policyDocumentId), - ); + const acknowledgedIds: ReadonlySet = canPersistAcknowledgments + ? new Set( + protocols + .filter((protocol) => + isPolicyDocumentAcknowledged(acknowledgmentsQuery.data ?? [], protocol.id, protocol.version), + ) + .map((protocol) => protocol.id), + ) + : new Set(); function updateDraftField(patch: Partial) { setDraft((current) => ({ ...current, ...patch })); @@ -176,7 +190,7 @@ export function useSafetyProtocolsModule(userRole: UserRole) { // Acknowledgment is a persisted, one-way action; re-acknowledging is idempotent. function acknowledge(id: string) { - if (!acknowledgedIds.has(id)) { + if (canPersistAcknowledgments && !acknowledgedIds.has(id)) { acknowledgeProtocol.mutate(id); } } @@ -187,6 +201,7 @@ export function useSafetyProtocolsModule(userRole: UserRole) { return { canManage, + canPersistAcknowledgments, protocols, acknowledgedIds, expandedId, diff --git a/frontend/src/business/safety-protocols/selectors.test.ts b/frontend/src/business/safety-protocols/selectors.test.ts index 86a5457..3f5cf39 100644 --- a/frontend/src/business/safety-protocols/selectors.test.ts +++ b/frontend/src/business/safety-protocols/selectors.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - canManageSafetyProtocols, - isSafetyDraftValid, -} from '@/business/safety-protocols/selectors'; +import { isSafetyDraftValid } from '@/business/safety-protocols/selectors'; import type { SafetyProtocolDraft } from '@/business/safety-protocols/types'; const validDraft: SafetyProtocolDraft = { @@ -13,16 +10,6 @@ const validDraft: SafetyProtocolDraft = { }; describe('safety protocol selectors', () => { - it('shares the policy-document management grant', () => { - expect(canManageSafetyProtocols('owner')).toBe(true); - expect(canManageSafetyProtocols('superintendent')).toBe(true); - expect(canManageSafetyProtocols('director')).toBe(true); - expect(canManageSafetyProtocols('office_manager')).toBe(true); - expect(canManageSafetyProtocols('teacher')).toBe(false); - expect(canManageSafetyProtocols('support_staff')).toBe(false); - expect(canManageSafetyProtocols('student')).toBe(false); - }); - it('requires a title and at least one non-empty step', () => { expect(isSafetyDraftValid(validDraft)).toBe(true); expect(isSafetyDraftValid({ ...validDraft, title: ' ' })).toBe(false); diff --git a/frontend/src/business/safety-protocols/selectors.ts b/frontend/src/business/safety-protocols/selectors.ts index 6e701f0..e2179df 100644 --- a/frontend/src/business/safety-protocols/selectors.ts +++ b/frontend/src/business/safety-protocols/selectors.ts @@ -1,16 +1,5 @@ -import type { UserRole } from '@/shared/types/app'; -import { canManagePolicies } from '@/business/policies/selectors'; import type { SafetyProtocolDraft } from '@/business/safety-protocols/types'; -/** - * Safety protocols are `policy_documents`, so authoring shares the policy - * management grant (`CREATE/UPDATE/DELETE_POLICY_DOCUMENTS`). The backend stays - * the source of truth; this only gates the management UI affordances. - */ -export function canManageSafetyProtocols(userRole: UserRole): boolean { - return canManagePolicies(userRole); -} - /** A protocol needs a title and at least one non-empty procedure step. */ export function isSafetyDraftValid(draft: SafetyProtocolDraft): boolean { return Boolean(draft.title.trim() && draft.steps.some((step) => step.trim())); diff --git a/frontend/src/business/safety-quiz/hooks.ts b/frontend/src/business/safety-quiz/hooks.ts index c01699a..7cdb8a4 100644 --- a/frontend/src/business/safety-quiz/hooks.ts +++ b/frontend/src/business/safety-quiz/hooks.ts @@ -33,10 +33,13 @@ import { parseSafetyQuizPayload, serializeSafetyQuizPayload, } from '@/business/safety-quiz/selectors'; -import type { SafetyQuiz, UserRole } from '@/shared/types/app'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; +import type { SafetyQuiz } from '@/shared/types/app'; import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import { usePermissions } from '@/shared/app/usePermissions'; +import { useScopeContext } from '@/shared/app/scope-context'; export function useSafetyQuizResults(weekOf?: string) { return useQuery({ @@ -62,7 +65,10 @@ export function useSaveSafetyQuizResult() { }); } -export function useSafetyQuizPage(userRole: UserRole): SafetyQuizPage { +export function useSafetyQuizPage(): SafetyQuizPage { + const permissions = usePermissions(); + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistResult = canPersistPersonalScopeResults(ownTenant, selectedTenant); const quizQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.safetyQbsQuiz, null, @@ -76,7 +82,8 @@ export function useSafetyQuizPage(userRole: UserRole): SafetyQuizPage { const [answers, setAnswers] = useState([]); const quiz = quizQuery.payload; const weekOf = getCurrentSafetyQuizWeek(new Date()); - const canViewCompliance = userRole === 'director' || userRole === 'superintendent'; + const canViewCompliance = permissions.has('READ_SAFETY_QUIZ_REPORTS'); + const canManageQuizContent = permissions.has('MANAGE_CONTENT_CATALOG'); const complianceQuery = useSafetyQuizCompliance(weekOf, canViewCompliance); const saveResultMutation = useSaveSafetyQuizResult(); const complianceRows = complianceQuery.data ?? []; @@ -119,14 +126,16 @@ export function useSafetyQuizPage(userRole: UserRole): SafetyQuizPage { setQuizComplete(true); setAnswers(nextAnswers); setScore(finalScore); - await saveResultMutation.mutateAsync({ - quizId: quiz.id, - quizTitle: quiz.title, - score: finalScore, - totalQuestions: quiz.questions.length, - answers: nextAnswers, - weekOf, - }); + if (canPersistResult) { + await saveResultMutation.mutateAsync({ + quizId: quiz.id, + quizTitle: quiz.title, + score: finalScore, + totalQuestions: quiz.questions.length, + answers: nextAnswers, + weekOf, + }); + } } function resetQuiz() { @@ -151,7 +160,8 @@ export function useSafetyQuizPage(userRole: UserRole): SafetyQuizPage { complianceRows, completionSummary: calculateSafetyQuizCompletionSummary(complianceRows), canViewCompliance, - canManageQuizContent: canViewCompliance, + canManageQuizContent, + canPersistResult, isQuizLoading: quizQuery.isLoading, isComplianceLoading: complianceQuery.isLoading, isSavingResult: saveResultMutation.isPending, diff --git a/frontend/src/business/safety-quiz/types.ts b/frontend/src/business/safety-quiz/types.ts index 5832512..feaf439 100644 --- a/frontend/src/business/safety-quiz/types.ts +++ b/frontend/src/business/safety-quiz/types.ts @@ -36,6 +36,7 @@ export interface SafetyQuizPage { readonly completionSummary: SafetyQuizCompletionSummary; readonly canViewCompliance: boolean; readonly canManageQuizContent: boolean; + readonly canPersistResult: boolean; readonly isQuizLoading: boolean; readonly isComplianceLoading: boolean; readonly isSavingResult: boolean; diff --git a/frontend/src/business/scope/hooks.ts b/frontend/src/business/scope/hooks.ts new file mode 100644 index 0000000..0b4d6ca --- /dev/null +++ b/frontend/src/business/scope/hooks.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; + +import { setActiveTenant } from '@/shared/api/httpClient'; +import { + getActiveTenant, + getScopeTier, + getScopeTierLabel, + type ScopeTier, +} from '@/business/scope/selectors'; +import type { CurrentUser } from '@/shared/types/auth'; +import type { ActiveTenant } from '@/shared/types/scope'; + +export interface ScopeState { + readonly tier: ScopeTier; + readonly tierLabel: string; + readonly activeTenant: ActiveTenant | null; +} + +/** + * Derives the signed-in user's scope tier and active tenant from the auth + * session. Pure function of the user object — call from the view layer with + * the authenticated user. + */ +export function useScope(user: CurrentUser | null | undefined): ScopeState { + return useMemo(() => { + const tier = getScopeTier(user); + return { + tier, + tierLabel: getScopeTierLabel(tier), + activeTenant: getActiveTenant(user), + }; + }, [user]); +} + +/** + * Sets the active tenant for API requests (used by ScopeProvider to sync + * drill-down state to the HTTP client). + */ +export function setActiveTenantForApi( + value: { readonly level: string; readonly id: string } | null, +): void { + setActiveTenant(value); +} diff --git a/frontend/src/business/scope/queries.ts b/frontend/src/business/scope/queries.ts new file mode 100644 index 0000000..e3de516 --- /dev/null +++ b/frontend/src/business/scope/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getScopeChildren } from '@/shared/api/scope'; +import type { TenantChild } from '@/shared/types/scope'; + +export interface TenantParent { + readonly level: string; + readonly id: string; +} + +interface UseTenantChildrenOptions { + readonly enabled?: boolean; + readonly limit?: number; +} + +/** + * Lists the child tenants below `parent` (or below the user's own tenant when + * `parent` is null) for the tenant switcher / drill-down cards. + */ +export function useTenantChildren( + parent?: TenantParent | null, + options?: UseTenantChildrenOptions, +) { + return useQuery<{ rows: TenantChild[] }>({ + queryKey: ['scope-children', parent?.level ?? 'self', parent?.id ?? 'self', options?.limit ?? null], + queryFn: () => getScopeChildren(parent?.level, parent?.id, { limit: options?.limit }), + enabled: options?.enabled ?? true, + retry: false, + }); +} diff --git a/frontend/src/business/scope/selectors.test.ts b/frontend/src/business/scope/selectors.test.ts new file mode 100644 index 0000000..e7d295d --- /dev/null +++ b/frontend/src/business/scope/selectors.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + canPersistPersonalScopeResults, + getActiveTenant, + getScopeTier, + getTenantInitials, +} from '@/business/scope/selectors'; +import type { CurrentUser } from '@/shared/types/auth'; + +function user(partial: Partial): CurrentUser { + return { id: 'u1', email: 'u@example.com', ...partial }; +} + +describe('scope selectors', () => { + describe('getScopeTier', () => { + it('returns external for no user', () => { + expect(getScopeTier(null)).toBe('external'); + }); + + it('returns global for globalAccess or the system scope', () => { + expect(getScopeTier(user({ app_role: { globalAccess: true } }))).toBe('global'); + expect(getScopeTier(user({ app_role: { scope: 'system' } }))).toBe('global'); + }); + + it('maps the role scope to the tier', () => { + expect(getScopeTier(user({ app_role: { scope: 'organization' } }))).toBe('organization'); + expect(getScopeTier(user({ app_role: { scope: 'school' } }))).toBe('school'); + expect(getScopeTier(user({ app_role: { scope: 'campus' } }))).toBe('campus'); + expect(getScopeTier(user({ app_role: { scope: 'class' } }))).toBe('class'); + expect(getScopeTier(user({ app_role: { scope: 'guest' } }))).toBe('external'); + }); + }); + + describe('getActiveTenant', () => { + it('is null for a global admin with no organization', () => { + expect(getActiveTenant(user({ app_role: { globalAccess: true } }))).toBeNull(); + }); + + it('returns the tenant at the user scope tier', () => { + expect( + getActiveTenant( + user({ app_role: { scope: 'campus' }, campus: { id: 'c1', name: 'Tigers' } }), + ), + ).toEqual({ level: 'campus', id: 'c1', name: 'Tigers', logo: null }); + + expect( + getActiveTenant( + user({ + app_role: { scope: 'school' }, + school: { id: 's1', name: 'North', logo: 'logo.png' }, + }), + ), + ).toEqual({ level: 'school', id: 's1', name: 'North', logo: 'logo.png' }); + + expect( + getActiveTenant( + user({ app_role: { scope: 'organization' }, organizations: { id: 'o1', name: 'Org' } }), + ), + ).toEqual({ level: 'organization', id: 'o1', name: 'Org', logo: null }); + }); + }); + + describe('getTenantInitials', () => { + it('builds initials from the first and last word', () => { + expect(getTenantInitials('Demo Academy North')).toBe('DN'); + }); + + it('takes the first two letters of a single word', () => { + expect(getTenantInitials('Tigers')).toBe('TI'); + }); + + it('falls back for an empty name', () => { + expect(getTenantInitials(null)).toBe('–'); + }); + }); + + describe('canPersistPersonalScopeResults', () => { + it('allows persistence when the user is in their own scope', () => { + expect(canPersistPersonalScopeResults({ level: 'school', id: 's1', name: 'North', logo: null }, null)).toBe(true); + }); + + it('allows persistence when selected tenant matches the own tenant', () => { + const tenant = { level: 'campus' as const, id: 'c1', name: 'Tigers', logo: null }; + + expect(canPersistPersonalScopeResults(tenant, tenant)).toBe(true); + }); + + it('blocks persistence for a parent user drilled into a child tenant', () => { + expect( + canPersistPersonalScopeResults( + { level: 'organization', id: 'o1', name: 'Demo', logo: null }, + { level: 'school', id: 's1', name: 'North', logo: null }, + ), + ).toBe(false); + }); + + it('blocks persistence for a global user drilled into a tenant', () => { + expect( + canPersistPersonalScopeResults( + null, + { level: 'organization', id: 'o1', name: 'Demo', logo: null }, + ), + ).toBe(false); + }); + }); +}); diff --git a/frontend/src/business/scope/selectors.ts b/frontend/src/business/scope/selectors.ts new file mode 100644 index 0000000..a3f10ac --- /dev/null +++ b/frontend/src/business/scope/selectors.ts @@ -0,0 +1,97 @@ +import type { CurrentUser } from '@/shared/types/auth'; +import type { ActiveTenant } from '@/shared/types/scope'; + +/** Authorization tier of the signed-in user (derived from `app_role.scope`). */ +export type ScopeTier = + | 'global' + | 'organization' + | 'school' + | 'campus' + | 'class' + | 'external'; + +export function getScopeTier(user: CurrentUser | null | undefined): ScopeTier { + if (!user) return 'external'; + if (user.app_role?.globalAccess) return 'global'; + switch (user.app_role?.scope) { + case 'system': + return 'global'; + case 'organization': + return 'organization'; + case 'school': + return 'school'; + case 'campus': + return 'campus'; + case 'class': + return 'class'; + default: + return 'external'; + } +} + +const SCOPE_TIER_LABELS: Record = { + global: 'Platform', + organization: 'Organization', + school: 'School', + campus: 'Campus', + class: 'Classroom', + external: 'External', +}; + +export function getScopeTierLabel(tier: ScopeTier): string { + return SCOPE_TIER_LABELS[tier]; +} + +/** + * The user's own active tenant — the tenant at their scope tier. Campuses carry + * no logo (name only); schools/classes/orgs may carry one. + */ +export function getActiveTenant( + user: CurrentUser | null | undefined, +): ActiveTenant | null { + if (!user) return null; + const tier = getScopeTier(user); + + if (tier === 'class' && user.classRoom?.id) { + return { level: 'class', id: user.classRoom.id, name: user.classRoom.name ?? null, logo: user.classRoom.logo ?? null }; + } + if (tier === 'campus' && user.campus?.id) { + return { level: 'campus', id: user.campus.id, name: user.campus.name ?? null, logo: null }; + } + if (tier === 'school' && user.school?.id) { + return { level: 'school', id: user.school.id, name: user.school.name ?? null, logo: user.school.logo ?? null }; + } + if (user.organizations?.id) { + return { level: 'organization', id: user.organizations.id, name: user.organizations.name ?? null, logo: null }; + } + return null; +} + +/** Two-letter initials for a tenant name, used as a logo fallback. */ +export function getTenantInitials(name: string | null): string { + if (!name) return '–'; + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return '–'; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); +} + +/** + * Personal saved states are reportable user data. They are valid only when the + * user is viewing their own tenant, not while a parent user is drilled into a + * child tenant. + */ +export function canPersistPersonalScopeResults( + ownTenant: ActiveTenant | null, + selectedTenant: ActiveTenant | null, +): boolean { + if (!selectedTenant) { + return true; + } + + return Boolean( + ownTenant + && ownTenant.level === selectedTenant.level + && ownTenant.id === selectedTenant.id, + ); +} diff --git a/frontend/src/business/sign-language/hooks.ts b/frontend/src/business/sign-language/hooks.ts index aa3fb8b..d32534d 100644 --- a/frontend/src/business/sign-language/hooks.ts +++ b/frontend/src/business/sign-language/hooks.ts @@ -6,11 +6,13 @@ import { filterSignLanguageItems, getSignLanguageProgressPercent, } from '@/business/sign-language/selectors'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import type { SignLanguagePage, SignLanguageVideoModalState, } from '@/business/sign-language/types'; import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; +import { useScopeContext } from '@/shared/app/scope-context'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import type { SignLanguageCategoryFilter, @@ -26,8 +28,11 @@ const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = { rememberTitle: '', rememberDescription: '', }; +const EMPTY_LEARNED_SIGN_IDS = new Set(); export function useSignLanguagePage(): SignLanguagePage { + const { ownTenant, selectedTenant } = useScopeContext(); + const canPersistProgress = canPersistPersonalScopeResults(ownTenant, selectedTenant); const signsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguageItems, [], @@ -36,7 +41,7 @@ export function useSignLanguagePage(): SignLanguagePage { CONTENT_CATALOG_TYPES.signLanguagePageContent, EMPTY_SIGN_LANGUAGE_PAGE_CONTENT, ); - const progress = useLearnedSignsProgress(); + const progress = useLearnedSignsProgress({ enabled: canPersistProgress }); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [selectedSignId, setSelectedSignId] = useState(null); @@ -60,12 +65,17 @@ export function useSignLanguagePage(): SignLanguagePage { () => signs.find((sign) => sign.id === selectedSignId) ?? null, [selectedSignId, signs], ); + const learnedSignIds = canPersistProgress ? progress.learnedSignIds : EMPTY_LEARNED_SIGN_IDS; const progressPercent = useMemo( - () => getSignLanguageProgressPercent(signs, progress.learnedSignIds), - [progress.learnedSignIds, signs], + () => getSignLanguageProgressPercent(signs, learnedSignIds), + [learnedSignIds, signs], ); async function toggleLearned(id: string) { + if (!canPersistProgress) { + return; + } + const sign = signs.find((item) => item.id === id); if (!sign) { @@ -80,13 +90,14 @@ export function useSignLanguagePage(): SignLanguagePage { filteredSigns, categories, filters, - learnedSignIds: progress.learnedSignIds, - learnedCount: progress.learnedSignIds.size, + learnedSignIds, + learnedCount: learnedSignIds.size, progressPercent, + canPersistProgress, selectedSign, pageContent: pageContentQuery.payload, - isLoading: signsQuery.isLoading || pageContentQuery.isLoading || progress.isLoading, - isSaving: progress.isSaving, + isLoading: signsQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading), + isSaving: canPersistProgress && progress.isSaving, signsError: signsQuery.error, pageContentError: pageContentQuery.error, progressErrorMessage: getOptionalErrorMessage(progress.error), diff --git a/frontend/src/business/sign-language/types.ts b/frontend/src/business/sign-language/types.ts index 6934b99..8299d59 100644 --- a/frontend/src/business/sign-language/types.ts +++ b/frontend/src/business/sign-language/types.ts @@ -39,6 +39,7 @@ export interface SignLanguagePage { readonly learnedSignIds: ReadonlySet; readonly learnedCount: number; readonly progressPercent: number; + readonly canPersistProgress: boolean; readonly selectedSign: SignItem | null; readonly pageContent: SignLanguagePageContent; readonly isLoading: boolean; diff --git a/frontend/src/business/staff-attendance/hooks.ts b/frontend/src/business/staff-attendance/hooks.ts index ed6039a..d9bc037 100644 --- a/frontend/src/business/staff-attendance/hooks.ts +++ b/frontend/src/business/staff-attendance/hooks.ts @@ -11,9 +11,10 @@ import { } from '@/business/staff-attendance/mappers'; import { mapApiListRows } from '@/shared/business/apiListRows'; -export function useStaffAttendanceRecords(filter?: StaffAttendanceFilter) { +export function useStaffAttendanceRecords(filter?: StaffAttendanceFilter, enabled = true) { return useQuery({ queryKey: [STAFF_ATTENDANCE_QUERY_KEYS.records, filter], + enabled, queryFn: () => mapApiListRows( listStaffAttendanceRecords(filter), toStaffAttendanceRecordViewModel, @@ -21,9 +22,10 @@ export function useStaffAttendanceRecords(filter?: StaffAttendanceFilter) { }); } -export function useStaffAttendanceSummary(filter?: StaffAttendanceFilter) { +export function useStaffAttendanceSummary(filter?: StaffAttendanceFilter, enabled = true) { return useQuery({ queryKey: [STAFF_ATTENDANCE_QUERY_KEYS.summary, filter], + enabled, queryFn: async () => { const response = await getStaffAttendanceSummary(filter); return toStaffAttendanceSummaryViewModel(response); diff --git a/frontend/src/business/staff-attendance/mappers.ts b/frontend/src/business/staff-attendance/mappers.ts index 7a8e388..83faba9 100644 --- a/frontend/src/business/staff-attendance/mappers.ts +++ b/frontend/src/business/staff-attendance/mappers.ts @@ -17,6 +17,8 @@ export function toStaffAttendanceRecordViewModel( note: dto.note, userName: dto.user_name, userRole: dto.user_role, + campusId: dto.campusId, + userId: dto.userId, }; } diff --git a/frontend/src/business/staff-attendance/types.ts b/frontend/src/business/staff-attendance/types.ts index 69c2c74..8261ee4 100644 --- a/frontend/src/business/staff-attendance/types.ts +++ b/frontend/src/business/staff-attendance/types.ts @@ -7,6 +7,8 @@ export interface StaffAttendanceRecordViewModel { readonly note: string | null; readonly userName: string; readonly userRole: string | null; + readonly campusId?: string | null; + readonly userId?: string; } export interface StaffAttendanceRollup { diff --git a/frontend/src/business/tenant-create/api.ts b/frontend/src/business/tenant-create/api.ts new file mode 100644 index 0000000..a2dc0ee --- /dev/null +++ b/frontend/src/business/tenant-create/api.ts @@ -0,0 +1,14 @@ +export { getScopeChildren } from '@/shared/api/scope'; +export { + createCampus, + createClass, + createSchoolWithFirstCampus, + deleteCampus, + deleteClass, + deleteOrganization, + deleteSchool, + updateCampus, + updateClass, + updateOrganization, + updateSchool, +} from '@/shared/api/tenants'; diff --git a/frontend/src/business/tenant-create/selectors.test.ts b/frontend/src/business/tenant-create/selectors.test.ts new file mode 100644 index 0000000..6fd50b8 --- /dev/null +++ b/frontend/src/business/tenant-create/selectors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + getRequiredParentLevel, + levelDepth, +} from '@/business/tenant-create/selectors'; + +describe('tenant-create selectors', () => { + describe('getRequiredParentLevel', () => { + it('maps each tenant type to its parent level', () => { + expect(getRequiredParentLevel('organization')).toBeNull(); + expect(getRequiredParentLevel('school')).toBe('organization'); + expect(getRequiredParentLevel('campus')).toBe('school'); + expect(getRequiredParentLevel('class')).toBe('campus'); + }); + }); + + describe('levelDepth', () => { + it('ranks tenant levels (global = 0)', () => { + expect(levelDepth('global')).toBe(0); + expect(levelDepth('external')).toBe(0); + expect(levelDepth('organization')).toBe(1); + expect(levelDepth('school')).toBe(2); + expect(levelDepth('campus')).toBe(3); + expect(levelDepth('class')).toBe(4); + }); + }); +}); diff --git a/frontend/src/business/tenant-create/selectors.ts b/frontend/src/business/tenant-create/selectors.ts new file mode 100644 index 0000000..5e97bf1 --- /dev/null +++ b/frontend/src/business/tenant-create/selectors.ts @@ -0,0 +1,49 @@ +import type { ScopeTier } from '@/business/scope/selectors'; + +export type TenantType = 'organization' | 'school' | 'campus' | 'class'; +/** Tenant levels that can be a parent for creation. */ +export type ParentLevel = 'organization' | 'school' | 'campus'; + +/** The parent tenant level a given type attaches to (null = top-level org). */ +export function getRequiredParentLevel(type: TenantType): ParentLevel | null { + switch (type) { + case 'organization': + return null; + case 'school': + return 'organization'; + case 'campus': + return 'school'; + case 'class': + return 'campus'; + } +} + +/** Ordered tenant levels; index + 1 = depth (global = 0). */ +export const TENANT_LEVELS: readonly ParentLevel[] = [ + 'organization', + 'school', + 'campus', +]; + +export function levelDepth(level: ScopeTier | ParentLevel): number { + switch (level) { + case 'organization': + return 1; + case 'school': + return 2; + case 'campus': + return 3; + case 'class': + return 4; + default: + // global / external + return 0; + } +} + +export const TENANT_TYPE_LABELS: Record = { + organization: 'Organization', + school: 'School', + campus: 'Campus', + class: 'Class', +}; diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 2be849e..5f1363f 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -12,8 +12,15 @@ import { type TopBarContentItem, type TopBarSearchResult, } from '@/business/top-bar/search'; -import { getAccessibleModules } from '@/business/app-shell/selectors'; +import { + useCommunicationEvents, +} from '@/business/communications/hooks'; +import { getScopedModules } from '@/business/app-shell/selectors'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; +import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; +import { useSafetyProtocols } from '@/business/safety-protocols/hooks'; +import { hasPermission } from '@/business/auth/permissions'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { MODULES } from '@/shared/constants/appData'; import type { @@ -27,13 +34,15 @@ import type { UseTopBarPageOptions, } from '@/business/top-bar/types'; import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; -import { shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; +import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; +import { useScopeContext } from '@/shared/app/scope-context'; const EMPTY_STRATEGIES: readonly Strategy[] = []; const EMPTY_SIGNS: readonly SignItem[] = []; const EMPTY_ZONES: readonly ZoneInfo[] = []; export function useTopBarPage({ + user, userRole, userName, campusInfo, @@ -46,23 +55,48 @@ export function useTopBarPage({ const [showNotifications, setShowNotifications] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [signOutError, setSignOutError] = useState(null); + const { tier, ownTenant, selectedTenant } = useScopeContext(); + const canPersistPersonalResults = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const effectiveTier = selectedTenant ? selectedTenant.level : tier; + const scopedModules = useMemo( + () => getScopedModules(MODULES, user, effectiveTier, selectedTenant !== null), + [effectiveTier, selectedTenant, user], + ); + const accessibleModuleIds = useMemo( + () => new Set(scopedModules.map((module) => module.id)), + [scopedModules], + ); - const zoneCheckIn = useTodayZoneCheckIn(); + const canUseZoneCheckIn = canPersistPersonalResults && canZoneCheckIn(user); + const zoneCheckIn = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const needsZoneCheckIn = shouldNudgeZoneCheckIn( - userRole, + user, zoneCheckIn.isLoading, zoneCheckIn.isCheckedInToday, ); - const notifications = buildTopBarNotifications({ needsZoneCheckIn }); + const communicationEvents = useCommunicationEvents(); + const acknowledgedCommunicationEventIds = useMemo(() => new Set(), []); + const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY'); + const canReadHandbook = canReceivePolicyNotifications && accessibleModuleIds.has('handbook'); + const canReadSafetyProtocols = canReceivePolicyNotifications && accessibleModuleIds.has('safety'); + const policyAcknowledgments = usePolicyAcknowledgments(canReceivePolicyNotifications && ( + canReadHandbook || canReadSafetyProtocols + )); + const handbookPolicies = usePolicies(canReadHandbook); + const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols); + const notifications = buildTopBarNotifications({ + needsZoneCheckIn, + communicationEvents: communicationEvents.data ?? [], + acknowledgedCommunicationEventIds, + handbookPolicies: handbookPolicies.data ?? [], + safetyProtocols: safetyProtocols.data ?? [], + policyAcknowledgments: policyAcknowledgments.data ?? [], + }); // Header search = accessible modules (local) + their product content from the // content catalog. Content is fetched lazily — only once the user types, and // only for modules the user can access. const hasQuery = searchQuery.trim().length > 0; - const accessibleModuleIds = useMemo( - () => new Set(getAccessibleModules(MODULES, userRole).map((module) => module.id)), - [userRole], - ); const moduleNameById = useMemo( () => new Map(MODULES.map((module) => [module.id, module.name])), [], @@ -96,8 +130,8 @@ export function useTopBarPage({ }, [strategiesQuery.payload, signsQuery.payload, zonesQuery.payload, moduleNameById]); const searchResults = useMemo( - () => buildTopBarSearchResults(MODULES, userRole, searchQuery, contentItems), - [userRole, searchQuery, contentItems], + () => buildTopBarSearchResults(scopedModules, user, searchQuery, contentItems), + [scopedModules, user, searchQuery, contentItems], ); function selectSearchResult(result: TopBarSearchResult) { diff --git a/frontend/src/business/top-bar/search.test.ts b/frontend/src/business/top-bar/search.test.ts index 2e8638c..8cff610 100644 --- a/frontend/src/business/top-bar/search.test.ts +++ b/frontend/src/business/top-bar/search.test.ts @@ -6,11 +6,21 @@ import { type TopBarContentItem, } from '@/business/top-bar/search'; import type { Module } from '@/shared/types/app'; +import type { CurrentUser } from '@/shared/types/auth'; + +function user(permissions: readonly string[]): CurrentUser { + return { + id: 'user-1', + email: 'user@example.com', + app_role: { globalAccess: false }, + permissions, + }; +} const modules: Module[] = [ - { id: 'dashboard', name: 'Home Dashboard', icon: 'home', roles: ['teacher', 'director'], color: '', routePath: '/dashboard' }, - { id: 'zones', name: 'Regulate your Zone', icon: 'layers', roles: ['teacher'], color: '', routePath: '/zones-of-regulation' }, - { id: 'director', name: 'Director Dashboard', icon: 'chart', roles: ['director'], color: '', routePath: '/director-dashboard' }, + { id: 'dashboard', name: 'Home Dashboard', icon: 'home', permissions: ['READ_DASHBOARD'], color: '', routePath: '/dashboard' }, + { id: 'zones', name: 'Regulate your Zone', icon: 'layers', permissions: ['READ_ZONES'], color: '', routePath: '/zones-of-regulation' }, + { id: 'director', name: 'Director Dashboard', icon: 'chart', permissions: ['READ_DIRECTOR_DASHBOARD'], color: '', routePath: '/director-dashboard' }, ]; const content: TopBarContentItem[] = [ @@ -20,12 +30,12 @@ const content: TopBarContentItem[] = [ describe('top bar search', () => { it('matches only accessible modules by name/id (case-insensitive)', () => { - const results = searchModules(modules, 'teacher', 'dash'); + const results = searchModules(modules, user(['READ_DASHBOARD', 'READ_ZONES']), 'dash'); expect(results.map((result) => result.moduleId)).toEqual(['dashboard']); // a teacher cannot see the director dashboard even though it matches "dash" expect(results.some((result) => result.moduleId === 'director')).toBe(false); // empty query → nothing - expect(searchModules(modules, 'teacher', ' ')).toEqual([]); + expect(searchModules(modules, user(['READ_DASHBOARD']), ' ')).toEqual([]); }); it('matches content items by label and carries the owning module', () => { @@ -35,9 +45,9 @@ describe('top bar search', () => { }); it('combines modules first, then content, capped', () => { - const results = buildTopBarSearchResults(modules, 'teacher', 'e', content, 2); + const results = buildTopBarSearchResults(modules, user(['READ_DASHBOARD', 'READ_ZONES']), 'e', content, 2); expect(results).toHaveLength(2); expect(results[0]?.kind).toBe('module'); - expect(buildTopBarSearchResults(modules, 'teacher', '', content)).toEqual([]); + expect(buildTopBarSearchResults(modules, user(['READ_DASHBOARD']), '', content)).toEqual([]); }); }); diff --git a/frontend/src/business/top-bar/search.ts b/frontend/src/business/top-bar/search.ts index 0faf920..4bb6875 100644 --- a/frontend/src/business/top-bar/search.ts +++ b/frontend/src/business/top-bar/search.ts @@ -1,5 +1,6 @@ import { getAccessibleModules } from '@/business/app-shell/selectors'; -import type { Module, ModuleId, UserRole } from '@/shared/types/app'; +import type { Module, ModuleId } from '@/shared/types/app'; +import type { CurrentUser } from '@/shared/types/auth'; export type TopBarSearchResultKind = 'module' | 'content'; @@ -31,7 +32,7 @@ function normalize(value: string): string { /** Accessible modules whose name (or id) matches the query. */ export function searchModules( modules: readonly Module[], - userRole: UserRole, + user: CurrentUser | null | undefined, query: string, ): TopBarSearchResult[] { const normalized = normalize(query); @@ -39,7 +40,7 @@ export function searchModules( return []; } - return getAccessibleModules(modules, userRole) + return getAccessibleModules(modules, user) .filter( (module) => module.name.toLowerCase().includes(normalized) || @@ -78,7 +79,7 @@ export function searchContentItems( /** Combined, capped results: modules first, then content. */ export function buildTopBarSearchResults( modules: readonly Module[], - userRole: UserRole, + user: CurrentUser | null | undefined, query: string, contentItems: readonly TopBarContentItem[], limit: number = TOP_BAR_SEARCH_RESULT_LIMIT, @@ -88,7 +89,7 @@ export function buildTopBarSearchResults( } return [ - ...searchModules(modules, userRole, query), + ...searchModules(modules, user, query), ...searchContentItems(contentItems, query), ].slice(0, limit); } diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 65f9091..edb0f9b 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -8,6 +8,46 @@ import { getTopBarRoleLabel, } from '@/business/top-bar/selectors'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +import type { CommunicationEventDto } from '@/shared/types/communications'; +import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments'; + +function createCommunicationEvent(overrides: Partial = {}): CommunicationEventDto { + return { + id: 'event-1', + title: 'Campus meeting', + date: '2026-06-15', + type: 'meeting', + targetLevel: 'campus', + roles: ['director'], + organizationId: 'org-1', + campusId: 'campus-1', + schoolId: null, + classId: null, + canceledEventId: null, + createdById: 'user-1', + updatedById: null, + createdAt: '2026-06-15T00:00:00Z', + updatedAt: '2026-06-15T00:00:00Z', + ...overrides, + }; +} + +function createPolicyAcknowledgment( + overrides: Partial = {}, +): PolicyAcknowledgmentDto { + return { + id: 'ack-1', + policyDocumentId: 'handbook-1', + version: 1, + userId: 'user-1', + acknowledgedAt: '2026-06-15T00:00:00Z', + organizationId: 'org-1', + campusId: null, + createdAt: '2026-06-15T00:00:00Z', + updatedAt: '2026-06-15T00:00:00Z', + ...overrides, + }; +} describe('top bar selectors', () => { it('surfaces an unread zone check-in nudge (linking to the zones page) only when needed', () => { @@ -25,6 +65,64 @@ describe('top bar selectors', () => { expect(getTopBarInitials(' Grace Hopper ')).toBe('GH'); }); + it('surfaces unacknowledged internal alerts in the notifications menu', () => { + const unread = buildTopBarNotifications({ + needsZoneCheckIn: false, + communicationEvents: [createCommunicationEvent()], + acknowledgedCommunicationEventIds: new Set(), + }); + + expect(unread).toHaveLength(1); + expect(unread[0]).toMatchObject({ + text: 'Internal alert: Campus meeting', + href: APP_ROUTE_PATHS.internalComm, + unread: true, + }); + + expect(buildTopBarNotifications({ + needsZoneCheckIn: false, + communicationEvents: [createCommunicationEvent()], + acknowledgedCommunicationEventIds: new Set(['event-1']), + })).toEqual([]); + }); + + it('surfaces unread handbook policies and safety protocols by document version', () => { + const unread = buildTopBarNotifications({ + needsZoneCheckIn: false, + handbookPolicies: [{ + id: 'handbook-1', + title: 'Parent Communication', + category: 'Communication', + content: 'Document families contact.', + version: 2, + lastUpdated: '2026-06-15', + updatedBy: 'Director', + }], + safetyProtocols: [{ + id: 'safety-1', + title: 'Fire Drill', + tag: 'fire', + steps: ['Line up'], + autismConsiderations: [], + version: 1, + lastUpdated: '2026-06-15', + author: 'Director', + }], + policyAcknowledgments: [ + createPolicyAcknowledgment({ policyDocumentId: 'handbook-1', version: 1 }), + createPolicyAcknowledgment({ id: 'ack-2', policyDocumentId: 'safety-1', version: 1 }), + ], + }); + + expect(unread).toEqual([{ + id: 'handbook-policy-handbook-1-v2', + text: 'Unread handbook policy: Parent Communication', + time: 'Version 2', + unread: true, + href: APP_ROUTE_PATHS.handbook, + }]); + }); + it('falls back to default campus label', () => { expect(getTopBarCampusLabel()).toBe('Current Campus'); }); diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts index 4eb8d53..87aee88 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -6,6 +6,11 @@ import type { UserRole, } from '@/shared/types/app'; import type { TopBarNotification } from '@/business/top-bar/types'; +import type { CommunicationEventDto } from '@/shared/types/communications'; +import type { PolicyViewModel } from '@/business/policies/types'; +import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types'; +import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments'; +import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors'; export function getTopBarInitials(name: string): string { return name @@ -41,6 +46,11 @@ const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; */ export function buildTopBarNotifications(input: { readonly needsZoneCheckIn: boolean; + readonly communicationEvents?: readonly CommunicationEventDto[]; + readonly acknowledgedCommunicationEventIds?: ReadonlySet; + readonly handbookPolicies?: readonly PolicyViewModel[]; + readonly safetyProtocols?: readonly SafetyProtocolViewModel[]; + readonly policyAcknowledgments?: readonly PolicyAcknowledgmentDto[]; }): readonly TopBarNotification[] { const notifications: TopBarNotification[] = []; @@ -54,5 +64,51 @@ export function buildTopBarNotifications(input: { }); } + for (const event of input.communicationEvents ?? []) { + if (input.acknowledgedCommunicationEventIds?.has(event.id)) { + continue; + } + + notifications.push({ + id: `internal-alert-${event.id}`, + text: `Internal alert: ${event.title}`, + time: new Date(event.date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }), + unread: true, + href: APP_ROUTE_PATHS.internalComm, + }); + } + + for (const policy of input.handbookPolicies ?? []) { + if (isPolicyDocumentAcknowledged(input.policyAcknowledgments ?? [], policy.id, policy.version)) { + continue; + } + + notifications.push({ + id: `handbook-policy-${policy.id}-v${policy.version}`, + text: `Unread handbook policy: ${policy.title}`, + time: `Version ${policy.version}`, + unread: true, + href: APP_ROUTE_PATHS.handbook, + }); + } + + for (const protocol of input.safetyProtocols ?? []) { + if (isPolicyDocumentAcknowledged(input.policyAcknowledgments ?? [], protocol.id, protocol.version)) { + continue; + } + + notifications.push({ + id: `safety-protocol-${protocol.id}-v${protocol.version}`, + text: `Unread safety protocol: ${protocol.title}`, + time: `Version ${protocol.version}`, + unread: true, + href: APP_ROUTE_PATHS.safety, + }); + } + return notifications; } diff --git a/frontend/src/business/top-bar/types.ts b/frontend/src/business/top-bar/types.ts index 1dd8507..c0c5e07 100644 --- a/frontend/src/business/top-bar/types.ts +++ b/frontend/src/business/top-bar/types.ts @@ -5,8 +5,10 @@ import type { UserRole, } from '@/shared/types/app'; import type { TopBarSearchResult } from '@/business/top-bar/search'; +import type { CurrentUser } from '@/shared/types/auth'; export interface TopBarProps { + readonly user: CurrentUser | null; readonly userRole: UserRole; readonly userName: string; readonly campusInfo?: CampusInfo; diff --git a/frontend/src/business/user-admin/api.ts b/frontend/src/business/user-admin/api.ts new file mode 100644 index 0000000..d855dd3 --- /dev/null +++ b/frontend/src/business/user-admin/api.ts @@ -0,0 +1,13 @@ +export { fileDownloadUrl } from '@/shared/api/files'; +export { listPermissions } from '@/shared/api/permissions'; +export { listRoles, type RoleRow } from '@/shared/api/roles'; +export { + createOwnerWithOrganization, + createUser, + deleteUser, + linkGuardianStudent, + listUsers, + updateUser, + type AdminUserRow, + type SaveUserData, +} from '@/shared/api/users'; diff --git a/frontend/src/business/user-admin/selectors.test.ts b/frontend/src/business/user-admin/selectors.test.ts new file mode 100644 index 0000000..0c293ff --- /dev/null +++ b/frontend/src/business/user-admin/selectors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { getRoleTenantInput } from '@/business/user-admin/selectors'; + +describe('user-admin selectors', () => { + describe('getRoleTenantInput', () => { + it('maps each role to the tenant context the creator must provide', () => { + expect(getRoleTenantInput('super_admin')).toBe('none'); + expect(getRoleTenantInput('owner')).toBe('org'); + expect(getRoleTenantInput('superintendent')).toBe('org'); + expect(getRoleTenantInput('principal')).toBe('school'); + expect(getRoleTenantInput('registrar')).toBe('school'); + expect(getRoleTenantInput('director')).toBe('campus'); + expect(getRoleTenantInput('office_manager')).toBe('campus'); + expect(getRoleTenantInput('teacher')).toBe('class'); + expect(getRoleTenantInput('support_staff')).toBe('class'); + expect(getRoleTenantInput('student')).toBe('class'); + expect(getRoleTenantInput('guardian')).toBe('guardian'); + }); + }); +}); diff --git a/frontend/src/business/user-admin/selectors.ts b/frontend/src/business/user-admin/selectors.ts new file mode 100644 index 0000000..c287a44 --- /dev/null +++ b/frontend/src/business/user-admin/selectors.ts @@ -0,0 +1,24 @@ +import type { UserRole } from '@/shared/types/app'; + +/** What tenant context a new user of a given role needs the creator to provide. */ +export type RoleTenantInput = 'none' | 'org' | 'school' | 'campus' | 'class' | 'guardian'; + +const ROLE_TENANT_INPUT: Record = { + super_admin: 'none', + system_admin: 'none', + owner: 'org', + superintendent: 'org', + principal: 'school', + registrar: 'school', + director: 'campus', + office_manager: 'campus', + teacher: 'class', + support_staff: 'class', + student: 'class', + guardian: 'guardian', + guest: 'none', +}; + +export function getRoleTenantInput(role: UserRole): RoleTenantInput { + return ROLE_TENANT_INPUT[role]; +} diff --git a/frontend/src/business/user-progress/hooks.ts b/frontend/src/business/user-progress/hooks.ts index 22ebae8..043b1d2 100644 --- a/frontend/src/business/user-progress/hooks.ts +++ b/frontend/src/business/user-progress/hooks.ts @@ -8,16 +8,31 @@ import { USER_PROGRESS_QUERY_KEYS, USER_PROGRESS_TYPES, } from '@/shared/constants/userProgress'; -import { toLearnedSignIds } from '@/business/user-progress/mappers'; -import { LearnedSignsState } from '@/business/user-progress/types'; +import { + toLearnedSignIds, + toProgressItemIds, +} from '@/business/user-progress/mappers'; +import { + ClassroomStrategyFavoritesState, + LearnedSignsState, +} from '@/business/user-progress/types'; import { selectApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; const EMPTY_LEARNED_SIGNS = new Set(); +const EMPTY_CLASSROOM_STRATEGY_FAVORITES = new Set(); -export function useLearnedSignsProgress(): LearnedSignsState { +interface UseLearnedSignsProgressOptions { + readonly enabled?: boolean; +} + +export function useLearnedSignsProgress( + options: UseLearnedSignsProgressOptions = {}, +): LearnedSignsState { + const enabled = options.enabled ?? true; const progressQuery = useQuery({ queryKey: USER_PROGRESS_QUERY_KEYS.signProgress, + enabled, queryFn: () => selectApiListRows( listUserProgress(USER_PROGRESS_TYPES.signLearned), toLearnedSignIds, @@ -39,6 +54,10 @@ export function useLearnedSignsProgress(): LearnedSignsState { }); async function toggleLearnedSign(id: string, word: string) { + if (!enabled) { + return; + } + const learnedSignIds = progressQuery.data ?? EMPTY_LEARNED_SIGNS; if (learnedSignIds.has(id)) { @@ -57,3 +76,61 @@ export function useLearnedSignsProgress(): LearnedSignsState { toggleLearnedSign, }; } + +interface UseClassroomStrategyFavoritesOptions { + readonly enabled?: boolean; +} + +export function useClassroomStrategyFavorites( + options: UseClassroomStrategyFavoritesOptions = {}, +): ClassroomStrategyFavoritesState { + const enabled = options.enabled ?? true; + const progressQuery = useQuery({ + queryKey: USER_PROGRESS_QUERY_KEYS.classroomStrategyFavorites, + enabled, + queryFn: () => selectApiListRows( + listUserProgress(USER_PROGRESS_TYPES.classroomStrategyFavorite), + toProgressItemIds, + ), + }); + + const saveMutation = useInvalidatingMutation({ + mutationFn: ({ id, title }: { readonly id: string; readonly title: string }) => upsertUserProgress({ + progress_type: USER_PROGRESS_TYPES.classroomStrategyFavorite, + item_id: id, + value: title, + }), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.classroomStrategyFavorites, + }); + + const deleteMutation = useInvalidatingMutation({ + mutationFn: (id: string) => deleteUserProgressByItem( + USER_PROGRESS_TYPES.classroomStrategyFavorite, + id, + ), + invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.classroomStrategyFavorites, + }); + + async function toggleFavoriteStrategy(id: string, title: string) { + if (!enabled) { + return; + } + + const favoriteStrategyIds = progressQuery.data ?? EMPTY_CLASSROOM_STRATEGY_FAVORITES; + + if (favoriteStrategyIds.has(id)) { + await deleteMutation.mutateAsync(id); + return; + } + + await saveMutation.mutateAsync({ id, title }); + } + + return { + favoriteStrategyIds: progressQuery.data ?? EMPTY_CLASSROOM_STRATEGY_FAVORITES, + isLoading: progressQuery.isLoading, + isSaving: saveMutation.isPending || deleteMutation.isPending, + error: progressQuery.error || saveMutation.error || deleteMutation.error, + toggleFavoriteStrategy, + }; +} diff --git a/frontend/src/business/user-progress/mappers.test.ts b/frontend/src/business/user-progress/mappers.test.ts index ac63eb0..74e6150 100644 --- a/frontend/src/business/user-progress/mappers.test.ts +++ b/frontend/src/business/user-progress/mappers.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { toLearnedSignIds } from '@/business/user-progress/mappers'; +import { + toLearnedSignIds, + toProgressItemIds, +} from '@/business/user-progress/mappers'; import type { UserProgressDto } from '@/shared/types/userProgress'; function createProgress(overrides: Partial = {}): UserProgressDto { @@ -29,4 +32,26 @@ describe('user progress mappers', () => { expect([...learnedSignIds].sort()).toEqual(['hello', 'help']); }); + + it('maps classroom strategy favorite rows to a unique id set', () => { + const favoriteStrategyIds = toProgressItemIds([ + createProgress({ + id: '1', + progress_type: 'classroom_strategy_favorite', + item_id: 'visual-schedule', + }), + createProgress({ + id: '2', + progress_type: 'classroom_strategy_favorite', + item_id: 'token-economy', + }), + createProgress({ + id: '3', + progress_type: 'classroom_strategy_favorite', + item_id: 'visual-schedule', + }), + ]); + + expect([...favoriteStrategyIds].sort()).toEqual(['token-economy', 'visual-schedule']); + }); }); diff --git a/frontend/src/business/user-progress/mappers.ts b/frontend/src/business/user-progress/mappers.ts index 54e20f5..585df6a 100644 --- a/frontend/src/business/user-progress/mappers.ts +++ b/frontend/src/business/user-progress/mappers.ts @@ -1,5 +1,7 @@ import { UserProgressDto } from '@/shared/types/userProgress'; -export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet { +export function toProgressItemIds(progress: readonly UserProgressDto[]): ReadonlySet { return new Set(progress.map((item) => item.item_id)); } + +export const toLearnedSignIds = toProgressItemIds; diff --git a/frontend/src/business/user-progress/types.ts b/frontend/src/business/user-progress/types.ts index f90c0d2..7d3104e 100644 --- a/frontend/src/business/user-progress/types.ts +++ b/frontend/src/business/user-progress/types.ts @@ -5,3 +5,11 @@ export interface LearnedSignsState { readonly error: Error | null; readonly toggleLearnedSign: (id: string, word: string) => Promise; } + +export interface ClassroomStrategyFavoritesState { + readonly favoriteStrategyIds: ReadonlySet; + readonly isLoading: boolean; + readonly isSaving: boolean; + readonly error: Error | null; + readonly toggleFavoriteStrategy: (id: string, title: string) => Promise; +} diff --git a/frontend/src/business/zone-checkin/hooks.ts b/frontend/src/business/zone-checkin/hooks.ts index 6a76c36..635d219 100644 --- a/frontend/src/business/zone-checkin/hooks.ts +++ b/frontend/src/business/zone-checkin/hooks.ts @@ -16,14 +16,13 @@ export const ZONE_CHECKIN_QUERY_KEYS = { /** * Today's Zone check-in for the caller. "Today" is resolved server-side in the - * campus timezone, so this hook never computes a date. `retry: false` so a - * caller without `ZONE_CHECKIN` (a non-campus role) silently gets no data - * instead of retrying a 403 — the nudge is role-gated anyway. + * campus timezone, so this hook never computes a date. */ -export function useTodayZoneCheckIn() { +export function useTodayZoneCheckIn(options: { readonly enabled?: boolean } = {}) { const todayQuery = useQuery({ queryKey: ZONE_CHECKIN_QUERY_KEYS.today, queryFn: getTodayZoneCheckin, + enabled: options.enabled ?? true, retry: false, }); diff --git a/frontend/src/business/zone-checkin/selectors.test.ts b/frontend/src/business/zone-checkin/selectors.test.ts index 52a744b..8d1ed1e 100644 --- a/frontend/src/business/zone-checkin/selectors.test.ts +++ b/frontend/src/business/zone-checkin/selectors.test.ts @@ -3,26 +3,38 @@ import { canZoneCheckIn, shouldNudgeZoneCheckIn, } from '@/business/zone-checkin/selectors'; +import type { CurrentUser } from '@/shared/types/auth'; + +function user( + permissions: readonly string[], + globalAccess = false, +): CurrentUser { + return { + id: 'user-1', + email: 'user@example.com', + app_role: { name: globalAccess ? 'super_admin' : 'teacher', globalAccess }, + permissions, + }; +} describe('zone check-in selectors', () => { - it('limits check-in to the four campus staff roles', () => { - expect(canZoneCheckIn('director')).toBe(true); - expect(canZoneCheckIn('office_manager')).toBe(true); - expect(canZoneCheckIn('teacher')).toBe(true); - expect(canZoneCheckIn('support_staff')).toBe(true); - expect(canZoneCheckIn('owner')).toBe(false); - expect(canZoneCheckIn('superintendent')).toBe(false); - expect(canZoneCheckIn('student')).toBe(false); - expect(canZoneCheckIn('guardian')).toBe(false); + it('checks the explicit ZONE_CHECKIN effective permission', () => { + expect(canZoneCheckIn(user(['ZONE_CHECKIN']))).toBe(true); + expect(canZoneCheckIn(user(['READ_ZONES']))).toBe(false); }); - it('nudges only an eligible role that has loaded and not checked in', () => { - expect(shouldNudgeZoneCheckIn('teacher', false, false)).toBe(true); + it('does not infer zone check-in from globalAccess alone', () => { + expect(canZoneCheckIn(user([], true))).toBe(false); + expect(canZoneCheckIn(user(['ZONE_CHECKIN'], true))).toBe(true); + }); + + it('nudges only a permitted user that has loaded and not checked in', () => { + expect(shouldNudgeZoneCheckIn(user(['ZONE_CHECKIN']), false, false)).toBe(true); // already checked in - expect(shouldNudgeZoneCheckIn('teacher', false, true)).toBe(false); + expect(shouldNudgeZoneCheckIn(user(['ZONE_CHECKIN']), false, true)).toBe(false); // still loading - expect(shouldNudgeZoneCheckIn('teacher', true, false)).toBe(false); - // ineligible role - expect(shouldNudgeZoneCheckIn('owner', false, false)).toBe(false); + expect(shouldNudgeZoneCheckIn(user(['ZONE_CHECKIN']), true, false)).toBe(false); + // missing permission + expect(shouldNudgeZoneCheckIn(user(['READ_ZONES']), false, false)).toBe(false); }); }); diff --git a/frontend/src/business/zone-checkin/selectors.ts b/frontend/src/business/zone-checkin/selectors.ts index 9cb52c4..4cc5887 100644 --- a/frontend/src/business/zone-checkin/selectors.ts +++ b/frontend/src/business/zone-checkin/selectors.ts @@ -1,24 +1,19 @@ -import type { UserRole } from '@/shared/types/app'; +import { hasPermission } from '@/business/auth/permissions'; +import type { CurrentUser } from '@/shared/types/auth'; /** - * Roles that perform a daily Zone self-regulation check-in — the four campus - * staff roles (mirrors the backend `ZONE_CHECKIN` grant). The nudge/banner and - * notification are shown to these roles only. + * Daily zone check-in is a campus-staff workflow. Global/full-access roles may + * technically pass permission checks, but they should not get self-state nudges. */ -export function canZoneCheckIn(userRole: UserRole): boolean { - return ( - userRole === 'director' || - userRole === 'office_manager' || - userRole === 'teacher' || - userRole === 'support_staff' - ); +export function canZoneCheckIn(user: CurrentUser | null | undefined): boolean { + return hasPermission(user, 'ZONE_CHECKIN'); } -/** Whether to nudge the user to check in: eligible role + loaded + not yet done today. */ +/** Whether to nudge the user to check in: permission + loaded + not yet done today. */ export function shouldNudgeZoneCheckIn( - userRole: UserRole, + user: CurrentUser | null | undefined, isLoading: boolean, isCheckedInToday: boolean, ): boolean { - return canZoneCheckIn(userRole) && !isLoading && !isCheckedInToday; + return canZoneCheckIn(user) && !isLoading && !isCheckedInToday; } diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 4b9a3fe..7adc78b 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -10,7 +10,7 @@ import Sidebar from '@/components/frameworks/Sidebar'; import TopBar from '@/components/frameworks/TopBar'; const AppLayout: React.FC = () => { - const { profile, loading: authLoading } = useAuth(); + const { user, profile, loading: authLoading } = useAuth(); const isMobile = useIsMobile(); const { mobileOverlayVisible, @@ -19,7 +19,7 @@ const AppLayout: React.FC = () => { shellOutletContext, footerProps, setMobileSidebarOpen, - } = useAppShell({ profile, isMobile }); + } = useAppShell({ user, profile, isMobile }); if (authLoading) { return ; diff --git a/frontend/src/components/app-shell/AppFooter.tsx b/frontend/src/components/app-shell/AppFooter.tsx index 6b34aa2..1c018a7 100644 --- a/frontend/src/components/app-shell/AppFooter.tsx +++ b/frontend/src/components/app-shell/AppFooter.tsx @@ -9,8 +9,15 @@ import { export function AppFooter({ userName, userRole, + modules, setCurrentModule, }: AppFooterProps) { + const accessibleModuleIds = new Set(modules.map((module) => module.id)); + const coreLinks = CORE_FOOTER_LINKS.filter((item) => accessibleModuleIds.has(item.moduleId)); + const operationLinks = OPERATIONS_FOOTER_LINKS.filter((item) => + accessibleModuleIds.has(item.moduleId), + ); + return (