diff --git a/CLAUDE.md b/CLAUDE.md index 888f0ff..e50bbbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ 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 @@ -47,24 +48,41 @@ docker compose down -v # Stop and remove (including DB data) ### Quick Start (Dev Mode) -Prerequisites: PostgreSQL running locally with database `schoolchain_dev` and `backend/.env` configured. +Prerequisites: PostgreSQL running locally with database `schoolchain_dev` (user: `postgres`, password: `postgres`). ```bash # Terminal 1 - Backend (port 8080) -cd backend -export $(grep -v '^#' .env | xargs) && npm run dev +cd backend && npm run dev # Terminal 2 - Frontend (port 3000) -cd frontend -npm run dev +cd frontend && npm run dev ``` - Frontend: http://localhost:3000 - Backend API: http://localhost:8080/api-docs/ -- Login: `admin@flatlogic.com` / `flatlogicAdmin123!` 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) @@ -109,13 +127,14 @@ Import direction: `API → Business → Data`. Never skip layers. Cross-cutting 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` - Database schema: `backend/docs/database-schema.md` (regenerate after schema changes) -- Integration plan: `docs/full-integration-refactor-plan.md` +- **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` diff --git a/backend/.env b/backend/.env index 61c7b01..c88bfa8 100644 --- a/backend/.env +++ b/backend/.env @@ -1,13 +1 @@ -PORT=8080 -SECRET_KEY=local_dev_secret_change_me - -# Database (local PostgreSQL) -DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_NAME=schoolchain_dev -DB_USER=postgres -DB_PASS=postgres - -SEED_ADMIN_PASSWORD=flatlogicAdmin123! -SEED_USER_PASSWORD=flatlogicUser123! -SEED_ADMIN_EMAIL=admin@flatlogic.com \ No newline at end of file +PORT=8080 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index abf8eb0..9a23f81 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,6 +25,8 @@ AUTH_COOKIE_SAME_SITE=lax AUTH_COOKIE_SECURE=false AUTH_COOKIE_MAX_AGE_MS=900000 AUTH_REFRESH_TOKEN_MAX_AGE_MS=1209600000 +# Retention grace before the `db:cleanup-tokens` job deletes expired refresh-token rows (default 7 days). +AUTH_REFRESH_TOKEN_RETENTION_MS=604800000 AUTH_COOKIE_DOMAIN= # Seed-only local credentials. Do not use production passwords here. @@ -35,8 +37,6 @@ SEED_USER_PASSWORD=replace_with_local_seed_password # Optional external integrations. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -MS_CLIENT_ID= -MS_CLIENT_SECRET= EMAIL_FROM=School Chain Manager EMAIL_HOST= EMAIL_PORT=587 diff --git a/backend/docs/academic_years.md b/backend/docs/academic_years.md index 868934a..ece1d5e 100644 --- a/backend/docs/academic_years.md +++ b/backend/docs/academic_years.md @@ -59,9 +59,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): - `importHash` (unique), `organizationId`, `createdById`, `updatedById`, timestamps. Associations: `belongsTo` organization, createdBy/updatedBy (users); `hasMany` -`classes_academic_year` (classes), `timetables_academic_year` (timetables), -`fee_plans_academic_year` (fee_plans). `findBy`/`GET /:id` eager-load -`classes_academic_year`, `timetables_academic_year`, `fee_plans_academic_year`, and +`classes_academic_year` (classes), `timetables_academic_year` (timetables). `findBy`/`GET /:id` eager-load +`classes_academic_year`, `timetables_academic_year`, and `organization` in a single `Promise.all`. List filters (`AcademicYearsFilter`): `id`, `name`, `calendarStart`+`calendarEnd` (matches rows @@ -83,4 +82,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `timetables`, - `fee_plans`, `organizations`, `permissions.md`. + `organizations`, `permissions.md`. diff --git a/backend/docs/assessment_results.md b/backend/docs/assessment_results.md index 90b6001..4dd871c 100644 --- a/backend/docs/assessment_results.md +++ b/backend/docs/assessment_results.md @@ -62,9 +62,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): - `importHash` (unique), `organizationId`, `assessmentId`, `studentId`, `createdById`, `updatedById`, timestamps. -Associations: `belongsTo` organization, assessment (assessments), student (students), -createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load `organization`, `assessment`, and -`student` in a single `Promise.all`. +Associations: `belongsTo` organization, assessment (assessments), +createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load `organization` and `assessment` in a single `Promise.all`. List filters (`AssessmentResultsFilter`): `id`, `remarks`, `scoreRange`, `active`, `grade_letter`, `assessment` (id or assessment `name`, `|`-separated, applied as an `include` @@ -87,5 +86,4 @@ None yet. ## Related -- Generic-CRUD contract: `backend-architecture.md`; related slices: `assessments`, `students`, - `organizations`, `permissions.md`. +- Generic-CRUD contract: `backend-architecture.md`; related slices: `assessments`, `organizations`, `permissions.md`. diff --git a/backend/docs/attendance_records.md b/backend/docs/attendance_records.md index fd210cb..d5f63f2 100644 --- a/backend/docs/attendance_records.md +++ b/backend/docs/attendance_records.md @@ -5,7 +5,7 @@ `attendance_records` is the per-student attendance entry within an attendance session — the present/absent/late/excused mark, optional minutes late, and remarks. It is a generic-CRUD slice assembled from the shared factories and belongs to one `attendance_sessions` row and one -`students` row. +row. This is the generic student/session attendance entity; staff attendance is the separate `staff_attendance` slice (documented elsewhere). @@ -70,7 +70,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): `createdById`, `updatedById`, timestamps (all UUID FKs nullable). Associations: `belongsTo` organization, attendance_session (`attendance_sessions`), -student (`students`), createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load organization, +student (), createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load organization, attendance_session, and student in a single `Promise.all`. List filters (`AttendanceRecordsFilter`): `id`, `remarks` (iLike), `minutes_lateRange`, @@ -95,4 +95,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_sessions`, - `students`, `permissions.md`. + `permissions.md`. diff --git a/backend/docs/attendance_sessions.md b/backend/docs/attendance_sessions.md index 86ed9c6..587a58d 100644 --- a/backend/docs/attendance_sessions.md +++ b/backend/docs/attendance_sessions.md @@ -94,4 +94,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`, - `classes`, `class_subjects`, `campuses`, `staff`, `students`, `permissions.md`. + `classes`, `class_subjects`, `campuses`, `staff`, `permissions.md`. diff --git a/backend/docs/audio-files.md b/backend/docs/audio-files.md new file mode 100644 index 0000000..a9c1e98 --- /dev/null +++ b/backend/docs/audio-files.md @@ -0,0 +1,85 @@ +# Audio Library + +Workstream 13 — a flexible classroom-timer sound library. A row is one of three +**kinds**: an uploaded `file`, an external `url`, or a synthesized `recipe`. + +## Purpose + +`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**. + +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). +Until an AI key is wired, the recipe is produced by a local stub +(`business/audio-files/generate.ts`); only that function's body changes when the +key lands — the persistence, playback and library wiring are the same. + +## Entity + +`audio_files`: `title`, `kind` (`file` | `url` | `recipe`), `url` (nullable — +set for `file`/`url`), `recipe` (nullable JSONB — set for `recipe`), `is_default` +(false for campus rows; reserved for future platform-global rows), nullable +`organizationId` + `campusId`. A null `organizationId` denotes a global row +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. + +## Routes (`/api/audio_files`) + +- `GET /` — list the caller's campus rows **plus** global defaults + (`organizationId` null). Requires `READ_AUDIO_FILES`. +- `POST /` — add a `file` / `url` / `recipe` row (campus-scoped). Requires + `MANAGE_AUDIO_FILES`. Body `{ data: { kind, title, url? , recipe? } }`. +- `PUT /:id`, `DELETE /:id` — edit/remove an own-organization row (never a + global default). Requires `MANAGE_AUDIO_FILES`. + +## Authorization + +- `READ_AUDIO_FILES` — all four campus roles (director via full access). +- `MANAGE_AUDIO_FILES` — `director`, `office_manager`, `teacher` (not + `support_staff`, who is read/play-only). + +Non-global users can only manage rows in their own organization; global defaults +(`organizationId` null) are read-only to them. List/scope is enforced in the +service via the shared access helpers. + +## Frontend wiring + +The classroom-timer sound picker (`business/classroom-timer`) merges the +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 +a delete affordance on their own rows; global defaults are read-only. + +## Tests + +- **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). +- **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`). +- **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. diff --git a/backend/docs/auth-profile.md b/backend/docs/auth-profile.md index 84d5306..eaad3bf 100644 --- a/backend/docs/auth-profile.md +++ b/backend/docs/auth-profile.md @@ -3,8 +3,8 @@ ## Purpose The auth subsystem owns sign-in, signup, the current-user profile contract -(`GET /api/auth/me`), password reset / email verification, OAuth (Google / -Microsoft) sign-in, and the permission enforcement model. This document covers +(`GET /api/auth/me`), password reset / email verification, OAuth (Google) +sign-in, and the permission enforcement model. This document covers the profile and permission concerns; the HttpOnly cookie session transport (access/refresh tokens, rotation, CSRF/origin, sign-out) is documented in `backend/docs/cookie-auth.md`. @@ -21,7 +21,9 @@ tokens, or raw Sequelize model objects. factory). - Service (BLL): `src/services/auth.ts` (class `Auth`, default export `AuthService`) with DTO shapes in `src/services/auth.types.ts`. -- Passport strategies: `src/auth/auth.ts` (JWT, Google, Microsoft). +- Passport strategies: `src/auth/auth.ts` (JWT via `passport-jwt`; Google via + the maintained, typed **`passport-google-oauth20`**). OAuth is wired for future + use and is not surfaced in the current UI. - Cookie helpers: `src/auth/cookies.ts` (used for the session transport; see `cookie-auth.md`). - Permission middleware: `src/middlewares/check-permissions.ts` @@ -35,7 +37,7 @@ tokens, or raw Sequelize model objects. 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/product-role mapping), `shared/errors/*` (`ForbiddenError`, + (role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`, `ValidationError`), `services/email/*` (verification / reset / invitation emails). @@ -75,15 +77,10 @@ OAuth endpoints: - `GET /api/auth/signin/google/callback` (Passport `google`, `failureRedirect: '/login'`) -> sets session cookies and redirects to `config.uiUrl` (no token query parameters). -- `GET /api/auth/signin/microsoft` -> redirects to Microsoft - (`passport.authenticate('microsoft', { scope: ['https://graph.microsoft.com/user.read openid'], state })`). -- `GET /api/auth/signin/microsoft/callback` (Passport `microsoft`, - `failureRedirect: '/login'`) -> sets session cookies and redirects to - `config.uiUrl`. OAuth users are resolved by `db.users.findOrCreate({ where: { email, provider } })` -in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or -`profile._json.userPrincipalName`. +in `src/auth/auth.ts`; the Google email comes from the typed +`profile.emails[0].value`. ## Access Rules @@ -95,11 +92,14 @@ in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or - `checkPermissions(permission)` allows the request if any of: 1. self-access bypass — `currentUser.id` equals `req.params.id` or `req.body.id`; - 2. the user's `custom_permissions` include `permission`; - 3. the effective role's permissions include `permission`. The effective - role is the user's `app_role`, or the cached seeded `Public` role - (`SPECIAL_ROLE_NAMES.PUBLIC`) when there is no assigned role. The - `Public` role is fetched once at module load and cached. + 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 + 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. - A denied request is passed `new ValidationError('auth.forbidden')`. - `checkCrudPermissions(name)` derives the permission as `${METHOD}_${ENTITY}` from the HTTP method and entity name, where the @@ -123,12 +123,13 @@ in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or `AuthService.currentUserProfile` returns (built from `findProfileById`): -- `id`, `email`, `firstName`, `lastName` +- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName` - `organizationId` - `organizations` — `OrganizationDto` `{ id, name }` or `null` -- `app_role` — `RoleDto` `{ id, name, globalAccess }` or `null` -- `productRole` — a `PRODUCT_ROLE_VALUES` value - (`teacher` | `para` | `office` | `director` | `superintendent`) +- `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 + 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`) @@ -141,10 +142,14 @@ Note: the profile payload does not include a `phoneNumber` field (`findProfileById` does not select it and `currentUserProfile` does not return it). -`productRole` resolution order (`getProductRole`): generated backend role name -via `GENERATED_ROLE_TO_PRODUCT_ROLE`, then staff type via -`STAFF_TYPE_TO_PRODUCT_ROLE`, else `PRODUCT_ROLE_VALUES.TEACHER`. Mappings live -in `src/shared/constants/roles.ts`. +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. Signup / signin behavior (`src/services/auth.ts`): @@ -180,4 +185,4 @@ None yet (no auth unit/e2e test under `backend/src`). - Cookie session transport: `backend/docs/cookie-auth.md`. - Frontend: `frontend/docs/auth-integration.md`. -- Role / product-role constants: `src/shared/constants/roles.ts`. +- Role constants (definitions, scopes, names): `src/shared/constants/roles.ts`. diff --git a/backend/docs/backend-architecture.md b/backend/docs/backend-architecture.md index c8a0756..5a7da71 100644 --- a/backend/docs/backend-architecture.md +++ b/backend/docs/backend-architecture.md @@ -35,6 +35,16 @@ The API layer must not: - Contain tenant/role/permission/workflow rules or DTO mapping. - Run database queries. +### Authentication and public routes + +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`. + +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`). + ## Layer 2: Business Logic (BLL) Location: @@ -134,9 +144,9 @@ Most modules are assembled from shared factories/helpers — keep them that way. Factories: `services/shared/crud-service.ts`, `api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts` (generic - over the repository's entity types — no casts). 23 of 26 entities use them; - entities with genuinely different behavior (`users` invitations, `documents` - DTO responses, `permissions` no-`globalAccess` queries) stay hand-written. + over the repository's entity types — no casts). 18 of 21 entities use them; + entities with genuinely different behavior (`users` invitations, + `permissions` no-`globalAccess` queries) stay hand-written. - **Repository (DAL)** = entity-specific `create`/`update`/`bulkImport`/`findBy`/ `findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`, @@ -161,6 +171,32 @@ Centralized — see `backend/docs/error-handling.md`. Handlers/services throw an `AppError` subclass; the terminal `error-handler` middleware turns it into the `{ message, code?, details? }` JSON body the frontend `ApiError` consumes. +### Global error handlers + +The server registers process-level handlers in `src/index.ts` to prevent crashes +from unhandled errors: + +- `process.on('uncaughtException')` — catches synchronous errors +- `process.on('unhandledRejection')` — catches unhandled promise rejections + +These log the error and allow the server to continue running. This protects +against crashes from misconfigured external services (e.g., SMTP without +credentials) or unexpected async failures. + +### Production credential guards + +Development defaults (DB credentials, SECRET_KEY) are hardcoded in +`shared/constants/app.ts` for local development convenience. However, these +defaults are **never applied in production-like environments**: + +- `shared/config/index.ts`: `requiredEnvWithDevDefault()` throws if `SECRET_KEY` + is missing when `NODE_ENV` is `production` or `dev_stage`. +- `db/models/index.ts`: `validateProductionDbConfig()` throws if any `DB_*` + credential is missing in production-like environments. + +This ensures the server fails fast with a clear error message rather than +silently using insecure defaults. + ## Enforcement & verification - `src/shared/architecture/import-boundaries.test.ts` enforces the import diff --git a/backend/docs/campus-attendance.md b/backend/docs/campus-attendance.md index 6baaf4e..d635c48 100644 --- a/backend/docs/campus-attendance.md +++ b/backend/docs/campus-attendance.md @@ -25,7 +25,7 @@ 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 either hold one of `CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager, finance officer) or have a derived product role in `CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES` (office, director, superintendent). Global-access roles pass `hasRoleAccess`. +- 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`. - 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`). @@ -47,6 +47,12 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_ - Summary date range filtering uses `requiredIsoDate` on `startDate`/`endDate` and applies `Op.gte` / `Op.lte` on `attendance_date`. - Invalid campus keys, dates, or summary payloads throw `ValidationError`; access failures throw `ForbiddenError`. +## Source-of-truth contract (Workstream 12) + +Per the customer decision (2026-06-11), the **source of truth for campus attendance is manual entry by the `office_manager`** (and the higher campus/tenant roles), via the `PUT` config/summary endpoints guarded by the `FILL_ATTENDANCE` permission. There is no automatic derivation from student-level records. + +**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). diff --git a/backend/docs/campus-catalog.md b/backend/docs/campus-catalog.md index 8797193..b1e02aa 100644 --- a/backend/docs/campus-catalog.md +++ b/backend/docs/campus-catalog.md @@ -50,7 +50,7 @@ false), `active` (BOOLEAN, not null, default false), `importHash` (unique, nulla `organizationId` (UUID, nullable), audit fields `createdById` / `updatedById`, and `createdAt` / `updatedAt` / `deletedAt`. The model is `paranoid` (soft delete) with `freezeTableName`. Associations include `belongsTo` organization, createdBy, updatedBy, and `hasMany` -students, staff, classes, timetables, attendance_sessions, invoices, messages, documents (all keyed +staff, classes, timetables, attendance_sessions, messages (all keyed on `campusId`). ## Behavior / Notes diff --git a/backend/docs/campuses.md b/backend/docs/campuses.md index e01bd74..35831e9 100644 --- a/backend/docs/campuses.md +++ b/backend/docs/campuses.md @@ -72,8 +72,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): `createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`. Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy -(users); `hasMany` `students_campus`, `staff_campus`, `classes_campus`, `timetables_campus`, -`attendance_sessions_campus`, `invoices_campus`, `messages_campus`, `documents_campus` (all keyed on +(users); `hasMany` `staff_campus`, `classes_campus`, `timetables_campus`, +`attendance_sessions_campus`, `messages_campus`, `documents_campus` (all keyed on `campusId`, `constraints: false`). `findBy` (backing `GET /:id`) returns the plain campus plus all eight `hasMany` collections and the @@ -103,5 +103,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: `students`, `staff`, `classes`, `timetables`, `attendance_sessions`, `invoices`, - `messages`, `documents` (all child records keyed on `campusId`), `permissions.md`. +- Related slices: `staff`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`. diff --git a/backend/docs/class_enrollments.md b/backend/docs/class_enrollments.md index b37008c..d1706fd 100644 --- a/backend/docs/class_enrollments.md +++ b/backend/docs/class_enrollments.md @@ -2,7 +2,7 @@ ## Purpose -`class_enrollments` is the per-organization join between `students` and `classes` — it records a +`class_enrollments` is the per-organization join between and `classes` — it records a student's enrollment in a class with its own dates and status. It is a generic-CRUD slice assembled from the shared factories; the backend is the source of truth for enrollment records. @@ -63,9 +63,9 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): - `importHash` (unique), `organizationId`, `classId`, `studentId`, `createdById`, `updatedById`, timestamps. -Associations: `belongsTo` organization, class (classes), student (students), +Associations: `belongsTo` organization, class (classes), createdBy/updatedBy (users). This model declares no `hasMany`. `findBy`/`GET /:id` eager-load -organization, class and student in a single `Promise.all` (the class association is exposed on +organization and class in a single `Promise.all` (the class association is exposed on the output as `class`). List filters (`ClassEnrollmentsFilter`): `id`, `class` (id or name, `|`-separated), `student` (id @@ -87,5 +87,5 @@ None yet. ## Related -- Generic-CRUD contract: `backend-architecture.md`; related slices: `students`, `classes`, +- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `permissions.md`. diff --git a/backend/docs/communications.md b/backend/docs/communications.md index e45f0ff..2544b0a 100644 --- a/backend/docs/communications.md +++ b/backend/docs/communications.md @@ -9,7 +9,7 @@ The communications slice exposes product-focused endpoints for parent messages a - 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` (`PRODUCT_ROLE_VALUES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. +- 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`. ## API All routes require JWT authentication. @@ -41,7 +41,7 @@ Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event ## 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 product-role values; an empty/missing array defaults to `['teacher','para','office','director']`; invalid values throw `ValidationError`). +- 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). - List pagination: both lists use `resolvePagination(limit, page)`. diff --git a/backend/docs/cookie-auth.md b/backend/docs/cookie-auth.md index 29dc720..d3921d8 100644 --- a/backend/docs/cookie-auth.md +++ b/backend/docs/cookie-auth.md @@ -9,6 +9,11 @@ reads, stores, or sends auth tokens manually. This document covers the cookie session transport, refresh rotation, CSRF/origin protection, and sign-out; the profile and permission model is documented in `backend/docs/auth-profile.md`. +The interactive OpenAPI spec (`/api-docs`) documents the cookie model under a +`cookieAuth` security scheme (the HttpOnly access cookie) and the full auth +surface (`@openapi` JSDoc in `src/routes/auth.ts`); the `UserProfile` response +schema is shared with `/me`, sign-in, and refresh. + ## Slice Files (by layer) - Route: `src/routes/auth.ts` (mounted at `/api/auth` in `src/index.ts`). @@ -45,8 +50,8 @@ Base path `/api/auth`. tokens are a no-op (still clears cookies and returns `204`). - `GET /api/auth/me` (JWT-authenticated) -> `200` the current user profile, authenticated from the access cookie. -- OAuth callbacks (`/signin/google/callback`, `/signin/microsoft/callback`) set - session cookies and redirect to `config.uiUrl` without token query parameters. +- The OAuth callback (`/signin/google/callback`) sets session cookies and + redirects to `config.uiUrl` without token query parameters. ## Access Rules @@ -147,9 +152,36 @@ OAuth/email credentials. configured allow-list. - Refresh-token reuse triggers family revocation. +## Operational maintenance (refresh-token retention) + +Refresh-token rows are persistent (the table is **not** paranoid) and rotation +revokes-and-replaces rather than deleting, so expired/revoked rows accumulate. +A maintenance command physically deletes rows that expired before a retention +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) +``` + +- **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The + command deletes rows whose `expiresAt` is older than `now - retentionMs`. +- **Why it is safe:** a row past `expiresAt` can no longer be presented (its + cookie is expired) and is no longer needed for reuse-detection, so deleting it + — revoked or not — does not affect valid sessions. The grace window keeps + recently-expired rows for short-term forensics. +- **Scheduling:** run it from cron or the platform scheduler (e.g. daily). It is + idempotent, logs an observable summary line (`[refresh-token-maintenance] …`), + and exits non-zero on failure. +- Logic lives in `services/refresh-token-maintenance.ts` + (`cleanupExpiredRefreshTokens` + the pure `computeRefreshTokenCutoff`), backed + by `AuthRefreshTokensDBApi.deleteExpiredBefore`. + ## Tests -None yet (no auth unit/e2e test under `backend/src`). +- `services/refresh-token-maintenance.test.ts` — the pure retention cutoff + (`computeRefreshTokenCutoff`): window subtraction, non-positive/invalid window + → cutoff = now, and cutoff ≤ now. ## Related diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index 8d6a703..a9a105b 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -1,17 +1,17 @@ # Database Schema > Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth. -> Regenerate after schema changes. Last generated: 2026-06-09. +> Regenerate after schema changes. Last generated: 2026-06-11. ## Overview - **Engine:** PostgreSQL via **Sequelize 6** (models in `backend/src/db/models`, typed data access in `backend/src/db/api`). -- **Models:** 38 tables. +- **Models:** 35 tables. - **Primary keys:** every table has a `uuid` `id` (default `UUIDV4`). - **Soft delete:** all tables are `paranoid` — rows are flagged with `deletedAt` instead of being physically removed. - **Timestamps:** `createdAt` / `updatedAt` are managed automatically. - **Audit:** `createdById` / `updatedById` reference `users` (aliases `createdBy` / `updatedBy`). -- **Multi-tenancy:** tenant-owned tables carry `organizationId` and are scoped to the current user's organization in `db/api` (see `full-integration-refactor-plan.md`, tenant boundary workstream). +- **Multi-tenancy:** tenant-owned tables carry `organizationId` and are scoped to the current user's organization in `db/api` (the tenant boundary is enforced via `db/api/shared/repository.ts` — `findOwnedByPk`/`tenantWhere`). - **Import idempotency:** `importHash` (unique) deduplicates seeded/imported rows. ### Type notes @@ -21,12 +21,12 @@ 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`, `students`, `guardians`, `staff` +- **Campuses & People:** `campuses`, `staff` - **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` -- **Finance / Billing:** `fee_plans`, `invoices`, `payments` - **Communication:** `messages`, `message_recipients`, `communication_events` -- **Content & Product modules:** `content_catalog`, `documents`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results` +- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results` +- **Policy & Audio:** `policy_documents`, `policy_acknowledgments`, `audio_files` - **System:** `file`, `auth_refresh_tokens` ## Relationship graph (foreign keys) @@ -38,12 +38,10 @@ erDiagram organizations ||--o{ academic_years : "organization" organizations ||--o{ assessment_results : "organization" assessments ||--o{ assessment_results : "assessment" - students ||--o{ assessment_results : "student" organizations ||--o{ assessments : "organization" class_subjects ||--o{ assessments : "class_subject" organizations ||--o{ attendance_records : "organization" attendance_sessions ||--o{ attendance_records : "attendance_session" - students ||--o{ attendance_records : "student" organizations ||--o{ attendance_sessions : "organization" campuses ||--o{ attendance_sessions : "campus" classes ||--o{ attendance_sessions : "class" @@ -58,7 +56,6 @@ erDiagram organizations ||--o{ campuses : "organization" organizations ||--o{ class_enrollments : "organization" classes ||--o{ class_enrollments : "class" - students ||--o{ class_enrollments : "student" organizations ||--o{ class_subjects : "organization" classes ||--o{ class_subjects : "class" subjects ||--o{ class_subjects : "subject" @@ -70,28 +67,14 @@ erDiagram staff ||--o{ classes : "homeroom_teacher" organizations ||--o{ communication_events : "organization" campuses ||--o{ communication_events : "campus" - organizations ||--o{ documents : "organization" - campuses ||--o{ documents : "campus" - organizations ||--o{ fee_plans : "organization" - academic_years ||--o{ fee_plans : "academic_year" - grades ||--o{ fee_plans : "grade" organizations ||--o{ frame_entries : "organization" campuses ||--o{ frame_entries : "campus" organizations ||--o{ grades : "organization" - organizations ||--o{ guardians : "organization" - students ||--o{ guardians : "student" - organizations ||--o{ invoices : "organization" - campuses ||--o{ invoices : "campus" - students ||--o{ invoices : "student" - fee_plans ||--o{ invoices : "fee_plan" organizations ||--o{ message_recipients : "organization" messages ||--o{ message_recipients : "message" organizations ||--o{ messages : "organization" campuses ||--o{ messages : "campus" users ||--o{ messages : "sent_by" - organizations ||--o{ payments : "organization" - invoices ||--o{ payments : "invoice" - staff ||--o{ payments : "received_by" organizations ||--o{ personality_quiz_results : "organization" campuses ||--o{ personality_quiz_results : "campus" users ||--o{ personality_quiz_results : "user" @@ -104,8 +87,6 @@ erDiagram organizations ||--o{ staff_attendance_records : "organization" campuses ||--o{ staff_attendance_records : "campus" users ||--o{ staff_attendance_records : "user" - organizations ||--o{ students : "organization" - campuses ||--o{ students : "campus" organizations ||--o{ subjects : "organization" organizations ||--o{ timetable_periods : "organization" timetables ||--o{ timetable_periods : "timetable" @@ -120,6 +101,13 @@ erDiagram organizations ||--o{ users : "organizations" organizations ||--o{ walkthrough_checkins : "organization" campuses ||--o{ walkthrough_checkins : "campus" + organizations ||--o{ policy_documents : "organization" + campuses ||--o{ policy_documents : "campus" + policy_documents ||--o{ policy_acknowledgments : "policyDocument" + organizations ||--o{ policy_acknowledgments : "organization" + users ||--o{ policy_acknowledgments : "user" + organizations ||--o{ audio_files : "organization" + campuses ||--o{ audio_files : "campus" ``` ## Table reference @@ -148,8 +136,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** `students` as `students_organization` (FK `organizationId`) -- **has many** `guardians` as `guardians_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`) @@ -158,14 +144,10 @@ _Relations:_ - **has many** `timetable_periods` as `timetable_periods_organization` (FK `organizationId`) - **has many** `attendance_sessions` as `attendance_sessions_organization` (FK `organizationId`) - **has many** `attendance_records` as `attendance_records_organization` (FK `organizationId`) -- **has many** `fee_plans` as `fee_plans_organization` (FK `organizationId`) -- **has many** `invoices` as `invoices_organization` (FK `organizationId`) -- **has many** `payments` as `payments_organization` (FK `organizationId`) - **has many** `assessments` as `assessments_organization` (FK `organizationId`) - **has many** `assessment_results` as `assessment_results_organization` (FK `organizationId`) - **has many** `messages` as `messages_organization` (FK `organizationId`) - **has many** `message_recipients` as `message_recipients_organization` (FK `organizationId`) -- **has many** `documents` as `documents_organization` (FK `organizationId`) #### `users` @@ -194,6 +176,7 @@ Authentication identities. `email` is required (login + primary contact). Belong | `updatedAt` | timestamptz | yes | — | audit | | `deletedAt` | timestamptz | yes | — | audit | | `app_roleId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK, campus scope for campus-bound roles | _Relations:_ @@ -203,16 +186,18 @@ _Relations:_ - **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`) +- **belongs to** `campuses` as `campus` (FK `campusId`) - **has many** `file` as `avatar` (FK `belongsToId`) #### `roles` -Named permission sets (RBAC). Linked to permissions M:N; `globalAccess` grants cross-tenant access. +Named permission sets (RBAC), the 11 first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles). | Column | Type | Null | Default | Notes | |---|---|---|---|---| | `id` | uuid | no | UUIDV4 | PK | | `name` | text | yes | — | | +| `scope` | enum | no | — | `system` \| `organization` \| `campus` \| `external` \| `guest` | | `globalAccess` | boolean | no | false | | | `importHash` | varchar | yes | — | unique, audit | | `createdAt` | timestamptz | yes | — | audit | @@ -275,80 +260,13 @@ A physical or online campus belonging to one organization. Parent of students, s _Relations:_ -- **has many** `students` as `students_campus` (FK `campusId`) - **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** `invoices` as `invoices_campus` (FK `campusId`) - **has many** `messages` as `messages_campus` (FK `campusId`) -- **has many** `documents` as `documents_campus` (FK `campusId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) -#### `students` - -Enrolled students. Belong to a campus and organization; have guardians, enrollments, attendance, results, invoices. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `student_number` | text | yes | — | | -| `first_name` | text | yes | — | | -| `last_name` | text | yes | — | | -| `gender` | enum | yes | — | | -| `date_of_birth` | timestamptz | yes | — | | -| `enrollment_date` | timestamptz | yes | — | | -| `status` | enum | yes | — | | -| `email` | text | yes | — | | -| `phone` | text | yes | — | | -| `address` | text | 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 | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **has many** `guardians` as `guardians_student` (FK `studentId`) -- **has many** `class_enrollments` as `class_enrollments_student` (FK `studentId`) -- **has many** `attendance_records` as `attendance_records_student` (FK `studentId`) -- **has many** `invoices` as `invoices_student` (FK `studentId`) -- **has many** `assessment_results` as `assessment_results_student` (FK `studentId`) -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `campuses` as `campus` (FK `campusId`) -- **has many** `file` as `photo` (FK `belongsToId`) - -#### `guardians` - -Guardians/contacts linked to a student. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `full_name` | text | yes | — | | -| `relationship` | enum | yes | — | | -| `phone` | text | yes | — | | -| `email` | text | yes | — | | -| `address` | text | yes | — | | -| `primary_contact` | boolean | no | false | | -| `importHash` | varchar | yes | — | unique, audit | -| `createdAt` | timestamptz | yes | — | audit | -| `updatedAt` | timestamptz | yes | — | audit | -| `deletedAt` | timestamptz | yes | — | audit | -| `organizationId` | uuid | yes | — | FK | -| `studentId` | uuid | yes | — | FK | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `students` as `student` (FK `studentId`) - #### `staff` Staff members, optionally linked to a `user` account; can be homeroom teacher, subject teacher, attendance taker, payment receiver. @@ -376,7 +294,6 @@ _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`) -- **has many** `payments` as `payments_received_by` (FK `received_byId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `users` as `user` (FK `userId`) @@ -407,7 +324,6 @@ _Relations:_ - **has many** `classes` as `classes_academic_year` (FK `academic_yearId`) - **has many** `timetables` as `timetables_academic_year` (FK `academic_yearId`) -- **has many** `fee_plans` as `fee_plans_academic_year` (FK `academic_yearId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) #### `grades` @@ -432,7 +348,6 @@ Grade levels (with `sort_order`). _Relations:_ - **has many** `classes` as `classes_grade` (FK `gradeId`) -- **has many** `fee_plans` as `fee_plans_grade` (FK `gradeId`) - **belongs to** `organizations` as `organization` (FK `organizationId`) #### `subjects` @@ -516,7 +431,6 @@ _Relations:_ - **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `classes` as `class` (FK `classId`) -- **belongs to** `students` as `student` (FK `studentId`) #### `class_subjects` @@ -656,7 +570,6 @@ _Relations:_ - **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `assessments` as `assessment` (FK `assessmentId`) -- **belongs to** `students` as `student` (FK `studentId`) ### Attendance @@ -715,7 +628,6 @@ _Relations:_ - **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `attendance_sessions` as `attendance_session` (FK `attendance_sessionId`) -- **belongs to** `students` as `student` (FK `studentId`) #### `campus_attendance_config` @@ -799,104 +711,6 @@ _Relations:_ - **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `users` as `user` (FK `userId`) -### Finance / Billing - -#### `fee_plans` - -Fee/tuition plans (billing cycle, total amount) for a grade in an academic year. Invoices are generated from these. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `name` | text | yes | — | | -| `billing_cycle` | enum | yes | — | | -| `total_amount` | decimal | yes | — | | -| `active` | boolean | no | false | | -| `notes` | text | yes | — | | -| `importHash` | varchar | yes | — | unique, audit | -| `createdAt` | timestamptz | yes | — | audit | -| `updatedAt` | timestamptz | yes | — | audit | -| `deletedAt` | timestamptz | yes | — | audit | -| `academic_yearId` | uuid | yes | — | FK | -| `organizationId` | uuid | yes | — | FK | -| `gradeId` | uuid | yes | — | FK | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **has many** `invoices` as `invoices_fee_plan` (FK `fee_planId`) -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) -- **belongs to** `grades` as `grade` (FK `gradeId`) - -#### `invoices` - -Invoices issued to a student (amounts, status) — optionally from a fee_plan. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `invoice_number` | text | yes | — | | -| `issue_date` | timestamptz | yes | — | | -| `due_date` | timestamptz | yes | — | | -| `subtotal` | decimal | yes | — | | -| `discount_amount` | decimal | yes | — | | -| `tax_amount` | decimal | yes | — | | -| `total_amount` | decimal | yes | — | | -| `balance_due` | decimal | yes | — | | -| `status` | enum | yes | — | | -| `notes` | text | yes | — | | -| `importHash` | varchar | yes | — | unique, audit | -| `createdAt` | timestamptz | yes | — | audit | -| `updatedAt` | timestamptz | yes | — | audit | -| `deletedAt` | timestamptz | yes | — | audit | -| `campusId` | uuid | yes | — | FK | -| `fee_planId` | uuid | yes | — | FK | -| `organizationId` | uuid | yes | — | FK | -| `studentId` | uuid | yes | — | FK | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **has many** `payments` as `payments_invoice` (FK `invoiceId`) -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `campuses` as `campus` (FK `campusId`) -- **belongs to** `students` as `student` (FK `studentId`) -- **belongs to** `fee_plans` as `fee_plan` (FK `fee_planId`) -- **has many** `file` as `attachments` (FK `belongsToId`) - -#### `payments` - -Payments against an invoice (amount, method, proof file). - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `receipt_number` | text | yes | — | | -| `paid_at` | timestamptz | yes | — | | -| `amount` | decimal | yes | — | | -| `method` | enum | yes | — | | -| `reference_code` | text | yes | — | | -| `notes` | text | yes | — | | -| `importHash` | varchar | yes | — | unique, audit | -| `createdAt` | timestamptz | yes | — | audit | -| `updatedAt` | timestamptz | yes | — | audit | -| `deletedAt` | timestamptz | yes | — | audit | -| `invoiceId` | uuid | yes | — | FK | -| `organizationId` | uuid | yes | — | FK | -| `received_byId` | uuid | yes | — | FK | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `invoices` as `invoice` (FK `invoiceId`) -- **belongs to** `staff` as `received_by` (FK `received_byId`) -- **has many** `file` as `proof` (FK `belongsToId`) - ### Communication #### `messages` @@ -999,34 +813,6 @@ Product content catalog. | `updatedAt` | timestamptz | yes | — | audit | | `deletedAt` | timestamptz | yes | — | audit | -#### `documents` - -Polymorphic document records with a `file` attachment, scoped to organization/campus. - -| Column | Type | Null | Default | Notes | -|---|---|---|---|---| -| `id` | uuid | no | UUIDV4 | PK | -| `entity_type` | enum | yes | — | | -| `entity_reference` | text | yes | — | | -| `name` | text | yes | — | | -| `category` | enum | yes | — | | -| `uploaded_at` | timestamptz | yes | — | | -| `notes` | text | 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 | -| `createdById` | uuid | yes | — | FK, audit | -| `updatedById` | uuid | yes | — | FK, audit | - -_Relations:_ - -- **belongs to** `organizations` as `organization` (FK `organizationId`) -- **belongs to** `campuses` as `campus` (FK `campusId`) -- **has many** `file` as `file` (FK `belongsToId`) - #### `frame_entries` Product-module "frame" entries. @@ -1183,6 +969,91 @@ _Relations:_ - **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `users` as `user` (FK `userId`) +### Policy & Audio + +#### `policy_documents` + +Unified store for the Safety Protocols and Handbook & Policies pages (Workstream 11). `category` selects the page; `tag` is the finer sub-category. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `title` | text | no | — | | +| `body` | text | yes | — | | +| `category` | enum | no | — | `safety_protocol` \| `handbook_policy` | +| `tag` | varchar | yes | — | sub-category / safety card icon | +| `author` | varchar | yes | — | display name of the creating user | +| `steps` | jsonb | yes | — | author-filled procedure steps (safety) | +| `autism_considerations` | jsonb | yes | — | author-filled considerations (safety) | +| `version` | integer | no | 1 | bumped on title/body/steps/considerations change | +| `active` | boolean | no | true | | +| `importHash` | varchar | yes | — | unique, audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **has many** `policy_acknowledgments` (FK `policyDocumentId`) + +#### `policy_acknowledgments` + +Per-user, per-version acknowledgment of a policy document. Not paranoid (no soft delete). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `policyDocumentId` | uuid | no | — | FK | +| `version` | integer | no | — | acknowledged document version | +| `userId` | uuid | no | — | FK | +| `acknowledgedAt` | timestamptz | no | — | | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | + +_Unique:_ (`userId`, `policyDocumentId`, `version`) — `policy_acknowledgments_user_document_version_unique`. + +_Relations:_ + +- **belongs to** `policy_documents` as `policyDocument` (FK `policyDocumentId`) +- **belongs to** `users` as `user` (FK `userId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + +#### `audio_files` + +Flexible classroom-timer sound library (Workstream 13). Each row is one `kind`; exactly one of `url` / `recipe` is populated. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `title` | text | no | — | | +| `kind` | enum | no | `file` | `file` \| `url` \| `recipe` | +| `url` | varchar | yes | — | set for `file` / `url` | +| `recipe` | jsonb | yes | — | synthesis params; set for `recipe` | +| `is_default` | boolean | no | false | platform-global default | +| `importHash` | varchar | yes | — | unique, audit | +| `organizationId` | uuid | yes | — | FK; null = global default | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + ### System #### `file` diff --git a/backend/docs/documents.md b/backend/docs/documents.md deleted file mode 100644 index bf7c8a8..0000000 --- a/backend/docs/documents.md +++ /dev/null @@ -1,111 +0,0 @@ -# Documents Backend - -## Purpose - -`documents` stores file/document metadata records (with related `file` uploads) attached to -school entities such as students, staff, classes, invoices, organizations, and campuses. The -slice is hand-written (not the generic CRUD factory): the service returns trimmed DTOs via -`toDocumentDto`, supports CSV export and CSV bulk import, and resolves related `organization`, -`campus`, and `file` on single-record reads. - -## Slice Files (by layer) - -- Route: `src/routes/documents.ts` (wires CRUD plus `bulk-import`, `count`, `autocomplete`, - `deleteByIds`; applies `checkCrudPermissions('documents')` to every route). -- Controller: `src/api/controllers/documents.controller.ts` (custom — maps DTOs, handles CSV - export and file upload). -- Service (BLL): `src/services/documents.ts` (exports `toDocumentDto`; wraps writes in - transactions; parses CSV buffers for bulk import). -- Repository (DAL): `src/db/api/documents.ts`. -- Model: `src/db/models/documents.ts`. -- Shared used: `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`, - `autocompleteByField`), `db/api/file.ts` (`FileDBApi.replaceRelationFiles`), `db/utils.ts` - (`Utils.uuid`, `Utils.ilike`), `shared/constants/pagination.ts` (`resolvePagination`), - `shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `shared/csv.ts` (`toCsv`), - `middlewares/upload.ts` (`processFile`), `middlewares/check-permissions.ts`, - `shared/errors/validation.ts` (`ValidationError`). - -## API - -All routes are mounted under `/api/documents` and require JWT authentication (mounted with -`authenticated` in `src/index.ts`). Every route additionally passes -`checkCrudPermissions('documents')`, which checks the permission `${METHOD}_DOCUMENTS` -(see `permissions.md`). - -- `POST /api/documents` -> `201`, the created document DTO. Request body: `{ data: }`. -- `POST /api/documents/bulk-import` -> `200` `true`. Multipart file upload (`processFile`); a CSV - buffer is parsed into rows. Missing file raises `ValidationError('importer.errors.invalidFileEmpty')`. -- `PUT /api/documents/:id` -> `200`, the updated document DTO. The controller calls - `Service.update(req.body.data, req.body.id, ...)` (it reads `req.body.id`, not `req.params.id`). -- `DELETE /api/documents/:id` -> `200` `true`. -- `POST /api/documents/deleteByIds` -> `200` `true`. Request body: `{ data: string[] }`. -- `GET /api/documents` -> `200` `{ rows, count }` where `rows` are DTOs. When `?filetype=csv`, - responds with a CSV attachment of fields `id, entity_reference, name, notes, uploaded_at`. -- `GET /api/documents/count` -> `200` `{ rows: [], count }` (count-only). -- `GET /api/documents/autocomplete` -> `200` array of `{ id, label }` matched on `name`. -- `GET /api/documents/:id` -> `200`, a single record (plain) with eager-resolved `organization`, - `campus`, and `file`. This response is NOT passed through `toDocumentDto`. - -## Access Rules - -Authorization is by CRUD permission only: `checkCrudPermissions('documents')` requires the -effective role (or a custom per-user permission) to hold `${METHOD}_DOCUMENTS`. There is no -additional role-name gate or owner check inside the service. The self-access bypass in -`check-permissions.ts` (matching `req.params.id`/`req.body.id` to the current user id) does not -meaningfully apply to documents since those ids are document ids, not user ids. - -## Tenant Scope - -- `findAll` scopes to `currentUser.organizationId` only when the user has a loaded - `organizations` association and an `organizationId`. Callers with `globalAccess` (from - `currentUser.app_role.globalAccess`) have the `organizationId` constraint removed, so they read - across organizations. -- On `create`, the document's organization is forced to `currentUser.organizationId` - (`setOrganization`), regardless of input. -- On `update`, organization is only changed when `data.organization` is provided: global-access - users may set the provided organization; non-global users are pinned back to - `currentUser.organizationId`. -- `campus` is set from input on create and update. - -## Data Contract - -Model columns (`src/db/models/documents.ts`): `id` (UUID PK), `entity_type` (ENUM: `student`, -`staff`, `class`, `invoice`, `organization`, `campus`, `other`), `entity_reference` (text), -`name` (text), `category` (ENUM: `policy`, `report`, `id`, `medical`, `consent`, `invoice`, -`receipt`, `other`), `uploaded_at` (date), `notes` (text), `importHash` (unique), `createdAt`, -`updatedAt`, `deletedAt` (paranoid soft-delete), `campusId`, `organizationId`, `createdById`, -`updatedById`. Associations: `belongsTo organizations` (as `organization`), `belongsTo campuses` -(as `campus`), `hasMany file` (as `file`, scoped relation upload), `belongsTo users` (as -`createdBy`/`updatedBy`). - -`toDocumentDto` exposes only: `id`, `entity_type`, `entity_reference`, `name`, `category`, -`uploaded_at`, `notes`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, -`updatedAt`. It deliberately omits `importHash`, `deletedAt`, and eager relations. - -List filters (`findAll`): `id`, `entity_reference`, `name`, `notes` (all ILIKE), -`uploaded_atRange`, `active`, `entity_type`, `category`, `campus` (filter-only inner join on id -or name, `|`-separated), `organization` (`|`-separated ids), `createdAtRange`, plus `field`/`sort` -ordering (defaults to `createdAt desc`) and `limit`/`page` pagination. - -## Behavior / Notes - -- All mutations (`create`, `bulkImport`, `update`, `deleteByIds`, `remove`) run inside a manual - Sequelize transaction (`db.sequelize.transaction()`), committing on success and rolling back on - error. -- `update` raises `ValidationError('documentsNotFound')` when the record does not exist. -- Bulk import parses the uploaded CSV (`csv-parser`) into rows, then `bulkCreate`s with - `ignoreDuplicates: true` and per-row `createdAt` staggered by `BULK_IMPORT_TIMESTAMP_STEP_MS` - to preserve ordering. Related files are attached per row via `replaceRelationFiles`. -- The list query selects only scalar columns (no eager org/file load); the `campus` filter join - selects no attributes (filter-only, inner join). - -## Tests - -None yet (no `documents` unit/e2e test under `src/`). - -## Related - -- Frontend: `frontend/docs/policies-integration.md` (the handbook/policies workflow reads and - mutates policy documents via `GET /api/documents?category=policy`, `POST`, `PUT`, `DELETE`). -- Backend slices: `permissions.md` (the `${METHOD}_DOCUMENTS` permission gate), and the `file` - upload relation used by `replaceRelationFiles`. diff --git a/backend/docs/fee_plans.md b/backend/docs/fee_plans.md deleted file mode 100644 index c78bc88..0000000 --- a/backend/docs/fee_plans.md +++ /dev/null @@ -1,89 +0,0 @@ -# Fee Plans Backend - -## Purpose - -`fee_plans` is the per-organization catalogue of fee plans (billing schedules) that invoices can -reference. It is a generic-CRUD slice assembled from the shared factories; the backend is the -source of truth for fee-plan records. - -## Slice Files (by layer) - -- Route: `src/routes/fee_plans.ts` — `createCrudRouter(controller, { permission: 'fee_plans' })`. -- Controller: `src/api/controllers/fee_plans.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/fee_plans.ts` — `createCrudService(DbApi, { notFoundCode: 'fee_plansNotFound' })`. -- Repository (DAL): `src/db/api/fee_plans.ts` (`Fee_plansDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/fee_plans.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`). - -## API - -The standard generic-CRUD surface (all under `/api/fee_plans`, JWT + `${METHOD}_FEE_PLANS` -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 `name`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `name`, `notes`, `total_amount`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('fee_plans')`, deriving - `READ_FEE_PLANS` / `CREATE_FEE_PLANS` / `UPDATE_FEE_PLANS` / `DELETE_FEE_PLANS` 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). - -## Data Contract - -Model columns (`paranoid`, soft-delete via `deletedAt`): - -- `id` (UUID PK), `name`, `notes` (TEXT, nullable). -- `billing_cycle` — ENUM `one_time` | `monthly` | `termly` | `annual`. -- `total_amount` — DECIMAL. -- `active` — BOOLEAN, `allowNull: false`, default `false`. -- `importHash` (unique), `academic_yearId`, `organizationId`, `gradeId`, `createdById`, - `updatedById`, timestamps. - -Associations: `belongsTo` organization, academic_year, grade, createdBy/updatedBy (users); -`hasMany` `invoices_fee_plan` (invoices). `findBy`/`GET /:id` eager-load `invoices_fee_plan`, -organization, academic_year, grade in a single `Promise.all`. - -List filters (`FeePlansFilter`): `id`, `name`, `notes`, `total_amountRange`, `active`, -`billing_cycle`, `academic_year` (id or name, `|`-separated), `grade` (id or name, `|`-separated), -`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. - -## Behavior / Notes - -- This slice has a real `active` BOOLEAN column. `create`/`bulkImport` default `active` to - `false`; `update` sets it when provided. -- `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: `findAll` applies the `active` filter twice — once via the shared - `filter.active === true || filter.active === 'true'` coercion and again via a redundant - `if (filter.active) where.active = filter.active` block (kept for source accuracy). - -## Tests - -None yet. - -## Related - -- Generic-CRUD contract: `backend-architecture.md`; related slices: `invoices`, `academic_years`, - `grades`, `permissions.md`. diff --git a/backend/docs/file.md b/backend/docs/file.md index c8080bb..2253429 100644 --- a/backend/docs/file.md +++ b/backend/docs/file.md @@ -10,9 +10,16 @@ file DAL when entity relations are persisted, not by the upload endpoint itself. ## Slice Files (by layer) - Route: `src/routes/file.ts` (thin wiring; `GET /download`, `POST /upload/:table/:field`). -- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`). +- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`). `download` + calls `assertCanDownloadFile` before serving. - Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`, - `downloadGCloud`, `deleteGCloud`, `initGCloud`). + `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.) - 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). @@ -23,11 +30,16 @@ file DAL when entity relations are persisted, not by the upload endpoint itself. ## API -- `GET /api/file/download?privateUrl=` -> downloads the file. No authentication middleware on - this route. The controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or - `NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`. - - Local: missing `privateUrl` -> `404`; otherwise streams via `res.download` from - `config.uploadDir`. +- `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`. + - Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path + traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download` + from `config.uploadDir`. - GCloud: serves `${hash}/${privateUrl}` from the bucket; file missing or error -> `404` `{ message }`. - `POST /api/file/upload/:table/:field` -> uploads a single file. Requires JWT authentication diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md index e01b816..67e74f5 100644 --- a/backend/docs/frame-entries.md +++ b/backend/docs/frame-entries.md @@ -34,8 +34,8 @@ 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 Administrator`, `Administrator`, `Platform Owner`, `Tenant Director`, - `Campus Manager`. Enforced by `assertCanEdit` via `hasRoleAccess`; a non-editor gets + 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. ## Tenant Scope diff --git a/backend/docs/grades.md b/backend/docs/grades.md index 56c4653..732fab1 100644 --- a/backend/docs/grades.md +++ b/backend/docs/grades.md @@ -58,8 +58,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): - `importHash` (unique), `organizationId`, `createdById`, `updatedById`, timestamps. Associations: `belongsTo` organization, createdBy/updatedBy (users); `hasMany` -`classes_grade` (classes), `fee_plans_grade` (fee_plans). `findBy`/`GET /:id` eager-load -`classes_grade`, `fee_plans_grade`, and `organization` in a single `Promise.all`. +`classes_grade` (classes). `findBy`/`GET /:id` eager-load +`classes_grade`, and `organization` in a single `Promise.all`. List filters (`GradesFilter`): `id`, `name`, `code`, `description`, `sort_orderRange`, `active`, `organization` (`|`-separated ids), `createdAtRange`, plus `field`/`sort` ordering and @@ -78,5 +78,4 @@ None yet. ## Related -- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `fee_plans`, - `organizations`, `permissions.md`. +- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `organizations`, `permissions.md`. diff --git a/backend/docs/guardians.md b/backend/docs/guardians.md deleted file mode 100644 index d2d8671..0000000 --- a/backend/docs/guardians.md +++ /dev/null @@ -1,86 +0,0 @@ -# Guardians Backend - -## Purpose - -`guardians` is the per-organization roster of student guardians/contacts, each optionally linked -to a single `student`. It is a generic-CRUD slice assembled from the shared factories; the -backend is the source of truth for guardian records. - -## Slice Files (by layer) - -- Route: `src/routes/guardians.ts` — `createCrudRouter(controller, { permission: 'guardians' })`. -- Controller: `src/api/controllers/guardians.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/guardians.ts` — `createCrudService(DbApi, { notFoundCode: 'guardiansNotFound' })`. -- Repository (DAL): `src/db/api/guardians.ts` (`GuardiansDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/guardians.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`). - -## API - -The standard generic-CRUD surface (all under `/api/guardians`, JWT + `${METHOD}_GUARDIANS` -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 - `full_name`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `full_name`, `phone`, `email`, `address`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('guardians')`, deriving - `READ_GUARDIANS` / `CREATE_GUARDIANS` / `UPDATE_GUARDIANS` / `DELETE_GUARDIANS` 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), `full_name`, `phone`, `email`, `address` (TEXT, nullable). -- `relationship` — ENUM `mother` | `father` | `guardian` | `other`. -- `primary_contact` — BOOLEAN, `allowNull: false`, default `false`. -- `importHash` (unique), `organizationId`, `studentId`, `createdById`, `updatedById`, timestamps. - -Associations: `belongsTo` organization, student, createdBy/updatedBy (users). `findBy`/`GET /:id` -eager-load organization and student in a single `Promise.all`. - -List filters (`GuardiansFilter`): `id`, `full_name`, `phone`, `email`, `address`, `relationship`, -`primary_contact`, `student` (id or `student_number`, `|`-separated), `organization` (id list, -`|`-separated), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. - -## Behavior / Notes - -- `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: `GuardiansFilter` 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: `students`, `organizations`, - `permissions.md`. diff --git a/backend/docs/index.md b/backend/docs/index.md index 4833e1f..4ca9ab0 100644 --- a/backend/docs/index.md +++ b/backend/docs/index.md @@ -22,24 +22,26 @@ Tenant Scope / Data Contract / Behavior / Tests / Related). - [`migrations-and-seeders.md`](migrations-and-seeders.md): the Umzug runner, file conventions, and how to author a migration/seeder. - [`error-handling.md`](error-handling.md): centralized `AppError` pipeline and error body shape. +- [`test-coverage.md`](test-coverage.md): backend test runner, utilities, and current unit coverage. ## Auth And Access - [`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): roles entity and role<->permission linkage. -- [`users.md`](users.md): users entity, invitations, and CSV bulk import. +- [`roles.md`](roles.md): the 11 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 +- [`audio-files.md`](audio-files.md): classroom-timer sound library (`file`/`url`/`recipe` kinds). - [`campus-attendance.md`](campus-attendance.md) - [`campus-catalog.md`](campus-catalog.md): public campus records and branding. - [`communications.md`](communications.md) - [`content-catalog.md`](content-catalog.md) -- [`documents.md`](documents.md) - [`frame-entries.md`](frame-entries.md) - [`personality-quiz-results.md`](personality-quiz-results.md) +- [`policy-documents.md`](policy-documents.md): unified Safety Protocols + Handbook & Policies store and per-version acknowledgments. - [`safety-quiz-results.md`](safety-quiz-results.md) - [`staff-attendance.md`](staff-attendance.md) - [`user-progress.md`](user-progress.md) @@ -50,16 +52,13 @@ 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: [`students.md`](students.md), [`staff.md`](staff.md), [`guardians.md`](guardians.md), - [`organizations.md`](organizations.md). +- People: [`staff.md`](staff.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), [`assessment_results.md`](assessment_results.md), [`grades.md`](grades.md). - Attendance: [`attendance_sessions.md`](attendance_sessions.md), [`attendance_records.md`](attendance_records.md). -- Finance: [`invoices.md`](invoices.md), [`payments.md`](payments.md), - [`fee_plans.md`](fee_plans.md). - Scheduling: [`timetables.md`](timetables.md), [`timetable_periods.md`](timetable_periods.md). - Messaging: [`messages.md`](messages.md), [`message_recipients.md`](message_recipients.md). - Access: [`campuses.md`](campuses.md) (authenticated CRUD), [`roles.md`](roles.md). diff --git a/backend/docs/invoices.md b/backend/docs/invoices.md deleted file mode 100644 index 8b2232e..0000000 --- a/backend/docs/invoices.md +++ /dev/null @@ -1,93 +0,0 @@ -# Invoices Backend - -## Purpose - -`invoices` is the per-organization billing-invoice ledger. It is a generic-CRUD slice assembled -from the shared factories; the backend is the source of truth for invoice records. - -## Slice Files (by layer) - -- Route: `src/routes/invoices.ts` — `createCrudRouter(controller, { permission: 'invoices' })`. -- Controller: `src/api/controllers/invoices.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/invoices.ts` — `createCrudService(DbApi, { notFoundCode: 'invoicesNotFound' })`. -- Repository (DAL): `src/db/api/invoices.ts` (`InvoicesDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/invoices.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`), - `db/api/file.ts` (`replaceRelationFiles` for the `attachments` relation). - -## API - -The standard generic-CRUD surface (all under `/api/invoices`, JWT + `${METHOD}_INVOICES` -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 - `invoice_number`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `invoice_number`, `notes`, `subtotal`, `discount_amount`, `tax_amount`, -`total_amount`, `balance_due`, `issue_date`, `due_date`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('invoices')`, deriving - `READ_INVOICES` / `CREATE_INVOICES` / `UPDATE_INVOICES` / `DELETE_INVOICES` 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). - -## Data Contract - -Model columns (`paranoid`, soft-delete via `deletedAt`): - -- `id` (UUID PK), `invoice_number`, `notes` (TEXT, nullable). -- `issue_date`, `due_date` — DATE. -- `subtotal`, `discount_amount`, `tax_amount`, `total_amount`, `balance_due` — DECIMAL. -- `status` — ENUM `draft` | `issued` | `partially_paid` | `paid` | `overdue` | `void`. -- `importHash` (unique), `campusId`, `fee_planId`, `organizationId`, `studentId`, `createdById`, - `updatedById`, timestamps. - -Associations: `belongsTo` organization, campus, student, fee_plan, createdBy/updatedBy (users); -`hasMany` `payments_invoice` (payments); `hasMany` file as `attachments` (scoped relation). -`findBy`/`GET /:id` eager-load `payments_invoice`, organization, campus, student, fee_plan, -attachments in a single `Promise.all`. - -List filters (`InvoicesFilter`): `id`, `invoice_number`, `notes`, `issue_dateRange`, -`due_dateRange`, `subtotalRange`, `discount_amountRange`, `tax_amountRange`, `total_amountRange`, -`balance_dueRange`, `status`, `campus` (id or name, `|`-separated), `student` (id or -student_number, `|`-separated), `fee_plan` (id or name, `|`-separated), `organization`, -`createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. - -## Behavior / Notes - -- `create`/`bulkImport`/`update` manage the `attachments` 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: `InvoicesFilter` accepts an `active` flag the model has no column for; it is currently - inert (kept for source accuracy). - -## Tests - -None yet. - -## Related - -- Generic-CRUD contract: `backend-architecture.md`; related slices: `payments`, `fee_plans`, - `students`, `campuses`, `file.md`, `permissions.md`. diff --git a/backend/docs/migrations-and-seeders.md b/backend/docs/migrations-and-seeders.md index 5db93c4..783254b 100644 --- a/backend/docs/migrations-and-seeders.md +++ b/backend/docs/migrations-and-seeders.md @@ -14,10 +14,20 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o `migrator.up()` + `seeder.up()`. - Schema snapshot: `src/db/initial-schema.ts` — DDL snapshot derived from the Sequelize models; the models remain the source of truth. -- Migrations: `src/db/migrations/*.ts` — currently `20260610000000-initial-schema.ts` (creates - the full schema from the snapshot). -- Seeders: `src/db/seeders/*.ts` — `admin-user`, `user-roles`, `product-campuses`, - `content-catalog` (+ payloads under `seeders/content-catalog-data/`). +- Migrations: `src/db/migrations/*.ts` — `20260610000000-initial-schema.ts` (creates the full + schema from the snapshot) and `20260610010000-add-role-scope-and-user-campus.ts` (adds the + NOT-NULL `roles.scope` enum and the nullable `users.campusId`). Phase 4 adds + `20260611000000-policy-documents-and-acknowledgments.ts` (the unified policy store + per-version + acknowledgments), `20260611010000-audio-files.ts` (the audio library) + + `20260611060000-audio-files-kinds.ts` (the `kind` enum / nullable `url` / `recipe` JSONB), and + `20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum). +- 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 + 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 + `20260611050000-policy-documents-seed.ts` (3 safety protocols + 4 handbook policies). Shared + fixture definitions live in `src/shared/constants/seed-fixtures.ts`. ## Mechanism diff --git a/backend/docs/organizations.md b/backend/docs/organizations.md index 1a05305..5798aa6 100644 --- a/backend/docs/organizations.md +++ b/backend/docs/organizations.md @@ -29,8 +29,11 @@ permission, all `200`) — see `backend-architecture.md` for the shared contract - `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`. +- `DELETE /:id` — returns `true`. Gated by the §3.3 relational policy + (`assertCanDeleteOrganization` in `routes/organizations.ts`): only `super_admin` / + `system_admin` / `owner` may delete a company; a `superintendent` is blocked even though it + holds `DELETE_ORGANIZATIONS`. +- `POST /deleteByIds` — body `{ data: string[] }`, returns `true` (same delete guard). - `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 `name`. @@ -44,6 +47,8 @@ permission, all `200`) — see `backend-architecture.md` for the shared contract `READ_ORGANIZATIONS` / `CREATE_ORGANIZATIONS` / `UPDATE_ORGANIZATIONS` / `DELETE_ORGANIZATIONS` per HTTP method. - Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`). +- Companies are created by the provisioning flow, not this CRUD: creating an `owner` user + auto-creates the organization (`services/users.ts` -> `OrganizationsDBApi.create`). ## Tenant Scope @@ -69,11 +74,9 @@ No ENUM columns. Associations: `belongsTo` createdBy/updatedBy (users); `hasMany` (all keyed by the child's `organizationId`): `users_organizations`, `campuses_organization`, `academic_years_organization`, -`grades_organization`, `subjects_organization`, `students_organization`, `guardians_organization`, -`staff_organization`, `classes_organization`, `class_enrollments_organization`, +`grades_organization`, `subjects_organization`, `staff_organization`, `classes_organization`, `class_enrollments_organization`, `class_subjects_organization`, `timetables_organization`, `timetable_periods_organization`, -`attendance_sessions_organization`, `attendance_records_organization`, `fee_plans_organization`, -`invoices_organization`, `payments_organization`, `assessments_organization`, +`attendance_sessions_organization`, `attendance_records_organization`, `assessments_organization`, `assessment_results_organization`, `messages_organization`, `message_recipients_organization`, `documents_organization`. `findBy`/`GET /:id` eager-load all of these in a single `Promise.all`. @@ -96,5 +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. `students`, `guardians`, - `staff`, `campuses`). + per-organization slice references this table via `organizationId` (e.g. `staff`, `campuses`). diff --git a/backend/docs/payments.md b/backend/docs/payments.md deleted file mode 100644 index 0b999f9..0000000 --- a/backend/docs/payments.md +++ /dev/null @@ -1,91 +0,0 @@ -# Payments Backend - -## Purpose - -`payments` is the per-organization record of payments received against invoices. It is a -generic-CRUD slice assembled from the shared factories; the backend is the source of truth for -payment records. - -## Slice Files (by layer) - -- Route: `src/routes/payments.ts` — `createCrudRouter(controller, { permission: 'payments' })`. -- Controller: `src/api/controllers/payments.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/payments.ts` — `createCrudService(DbApi, { notFoundCode: 'paymentsNotFound' })`. -- Repository (DAL): `src/db/api/payments.ts` (`PaymentsDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/payments.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`), - `db/api/file.ts` (`replaceRelationFiles` for the `proof` relation). - -## API - -The standard generic-CRUD surface (all under `/api/payments`, JWT + `${METHOD}_PAYMENTS` -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 - `receipt_number`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `receipt_number`, `reference_code`, `notes`, `amount`, `paid_at`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('payments')`, deriving - `READ_PAYMENTS` / `CREATE_PAYMENTS` / `UPDATE_PAYMENTS` / `DELETE_PAYMENTS` 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). - -## Data Contract - -Model columns (`paranoid`, soft-delete via `deletedAt`): - -- `id` (UUID PK), `receipt_number`, `reference_code`, `notes` (TEXT, nullable). -- `paid_at` — DATE. -- `amount` — DECIMAL. -- `method` — ENUM `cash` | `bank_transfer` | `card` | `mobile_money` | `cheque` | `other`. -- `importHash` (unique), `invoiceId`, `organizationId`, `received_byId`, `createdById`, - `updatedById`, timestamps. - -Associations: `belongsTo` organization, invoice, received_by (staff), createdBy/updatedBy -(users); `hasMany` file as `proof` (scoped relation). `findBy`/`GET /:id` eager-load -organization, invoice, received_by, proof in a single `Promise.all`. - -List filters (`PaymentsFilter`): `id`, `receipt_number`, `reference_code`, `notes`, -`paid_atRange`, `amountRange`, `method`, `invoice` (id or invoice_number, `|`-separated), -`received_by` (id or employee_number, `|`-separated), `organization`, `createdAtRange`, plus -`field`/`sort` ordering and `limit`/`page` pagination. - -## Behavior / Notes - -- `create`/`bulkImport`/`update` manage the `proof` 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: `PaymentsFilter` accepts an `active` flag the model has no column for; it is currently - inert (kept for source accuracy). - -## Tests - -None yet. - -## Related - -- Generic-CRUD contract: `backend-architecture.md`; related slices: `invoices`, `staff`, - `file.md`, `permissions.md`. diff --git a/backend/docs/permissions.md b/backend/docs/permissions.md index 8959eed..caab50b 100644 --- a/backend/docs/permissions.md +++ b/backend/docs/permissions.md @@ -22,7 +22,7 @@ authorization middleware checks them per request. This slice manages the permiss - Shared used: `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`), `db/utils.ts` (`Utils.uuid`, `Utils.ilike`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), - `shared/constants/roles.ts` (`SPECIAL_ROLE_NAMES`), `shared/csv.ts` (`toCsv`), + `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/csv.ts` (`toCsv`), `middlewares/upload.ts` (`processFile`), `db/api/roles.ts` (`RolesDBApi`, used by the middleware), `shared/object.ts` (`isRecord`), `shared/logger.ts`, `shared/errors/validation.ts`. @@ -57,16 +57,17 @@ service or repository. `checkCrudPermissions(name)` derives a permission string from the HTTP method and entity name: `${METHOD_MAP[req.method]}_${name.toUpperCase()}` where `METHOD_MAP` is `POST→CREATE`, `GET→READ`, `PUT→UPDATE`, `PATCH→UPDATE`, `DELETE→DELETE`. For example a `GET` on -the `users` router requires `READ_USERS`; a `POST` on `documents` requires `CREATE_DOCUMENTS`. It +the `users` router requires `READ_USERS`; a `POST` on `assessments` requires `CREATE_ASSESSMENTS`. It then delegates to `checkPermissions(permissionName)`. `checkPermissions(permission)` allows the request when any of the following holds, in order: -1. Self-access bypass: `currentUser.id === req.params.id` or `currentUser.id === req.body.id`. +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 `name` equals the required permission. 3. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise - the `Public` role (`SPECIAL_ROLE_NAMES.PUBLIC`), which is fetched once at module load via + 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 eager-loaded `permissions` array when present, otherwise calls `getPermissions()`. Access is @@ -77,6 +78,21 @@ On denial the middleware logs the role name and the denied permission and calls a `getPermissions()` method, or a missing/unfetchable `Public` role, surfaces an Internal Server Error via `next(new Error(...))`. +## Product-feature permissions (§3.2) + +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** +`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 +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. + ## Tenant Scope None. The permission catalog is global; `findAll` applies no organization filter. diff --git a/backend/docs/personality-quiz-results.md b/backend/docs/personality-quiz-results.md index de96bcf..7392f70 100644 --- a/backend/docs/personality-quiz-results.md +++ b/backend/docs/personality-quiz-results.md @@ -40,8 +40,8 @@ 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 Administrator`, - `Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or any role with +- `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. diff --git a/backend/docs/policy-documents.md b/backend/docs/policy-documents.md new file mode 100644 index 0000000..7512467 --- /dev/null +++ b/backend/docs/policy-documents.md @@ -0,0 +1,111 @@ +# Policy Documents & Acknowledgments + +Workstream 11 — persistence of staff acknowledgment of policy/safety documents. + +## Purpose + +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 +**per document version**: editing a document bumps its `version`, which requires +re-acknowledgment. + +## Frontend wiring + +The two existing pages consume this single store (the old generated `documents` +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` + (`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former + local-state set. +- **Safety Protocols** (`business/safety-protocols`) consumes + `category = safety_protocol`, rendering author-filled `steps` + autism + considerations with a static per-`tag` card icon (fire/shield/heart). It shares + the persistent acknowledgment hooks. Both pages are seeded from + `20260611050000-policy-documents-seed.ts` (safety protocols reuse the former + content-catalog `safetyProtocols` payload; a few handbook policies seed the + handbook page). The Safety Protocols page also has a manager-gated + **structured-authoring** flow (mirrors the F.R.A.M.E. module: header + *New Protocol* button → create form, per-card *Edit*/*Delete*) with + **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 + require re-acknowledgment. + +## Entities + +- `policy_documents` (generic-CRUD entity): `title`, `body`, `category` + (`safety_protocol` | `handbook_policy` — selects the page), `tag` (nullable + finer **sub-category**; the Handbook page maps its + Operations/Behavior/Safety/Communication/Legal categorisation onto it, and the + Safety page uses it to pick the static card icon), `author` (display name of + the **creating user**, set server-side at creation and not changed on update), + `steps` + `autism_considerations` (JSONB string arrays — **author-filled + structured content** for safety protocols; null for handbook policies), + `version` (bumped when `title`/`body`/`steps`/`autism_considerations` change), + `active`, tenant `organizationId` + nullable `campusId`. This is the **single + unified store** for both the Safety Protocols and Handbook & Policies pages + (filter by `?category=` and optionally `?tag=`). The category **list** + icons + are static frontend config; each document's category assignment (`tag`) is DB + data. `author` is derived from the current user's name — + `${name_prefix} firstName lastName` (the honorific title from `users.name_prefix`, + e.g. "Dr. Sarah Williams"), else email. +- `policy_acknowledgments` (per-user): one row per (`userId`, `policyDocumentId`, + `version`), with `acknowledgedAt`. Unique index on those three columns; + acknowledging is idempotent for a given version. + +## Routes + +- `GET/POST /api/policy_documents`, `PUT/DELETE /api/policy_documents/:id`, + plus the standard generic-CRUD extras — guarded by + `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 + `checkPermissions('ACK_POLICY')`. + +## Authorization + +- `READ_POLICY_DOCUMENTS` — granted to the four campus roles (director via full + access; office_manager/teacher/support via the read-only entity grant). + `student`/`guardian` get no policy-document access. +- `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`). + +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. + +## Tests + +- **Unit** (`backend/src/shared/constants/policy-documents.test.ts` + + `users.test.ts`, `npm test`): the pure domain rules — + `isPolicyDocumentCategory` validation, the `nextPolicyDocumentVersion` + re-acknowledgment bump, and `formatPersonName` (author rendering). +- **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` + (management grant + draft validation for the authoring form). +- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`, + `npm run test:e2e:content`): document create/persist, manage-vs-read RBAC + (director/office_manager manage; teacher reads but cannot create), idempotent + per-version acknowledgment, version-bump re-acknowledgment, and external-role + lockout. + +## 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`). diff --git a/backend/docs/roles.md b/backend/docs/roles.md index 334ba15..32a5261 100644 --- a/backend/docs/roles.md +++ b/backend/docs/roles.md @@ -59,7 +59,9 @@ The standard generic-CRUD surface (all under `/api/roles`, JWT + `${METHOD}_ROLE Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): -- `id` (UUID PK), `name` (TEXT, nullable), `globalAccess` (BOOLEAN, not null, default `false`), +- `id` (UUID PK), `name` (TEXT, nullable), `scope` (ENUM + `system | organization | campus | external | guest`, NOT NULL), + `globalAccess` (BOOLEAN, not null, default `false`), `importHash` (STRING(255), unique, nullable), `createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`. @@ -104,10 +106,15 @@ 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 globalAccess roles**: The seeder (`20200430130760-user-roles.ts`) sets `globalAccess: true` - for both `Super Administrator` and `Administrator` roles. Users with these roles can access data - across all organizations without an `organizationId` filter. Services use `getOrganizationIdOrGlobal` - and `hasGlobalAccess` from `services/shared/access.ts` to check for and honor global access. +- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the 11 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 + (`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 + preset role→permission matrix. Services use `getOrganizationIdOrGlobal` / `hasGlobalAccess` + (`services/shared/access.ts`) to honor global access. ## Tests diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md index f1ed130..c9f35d2 100644 --- a/backend/docs/safety-quiz-results.md +++ b/backend/docs/safety-quiz-results.md @@ -18,7 +18,7 @@ role snapshot, and persistence. Each submission is an append (create) — there - 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` - (`GENERATED_ROLE_TO_PRODUCT_ROLE`, `PRODUCT_ROLE_VALUES`); `shared/constants/safety-quiz.ts` + (`ROLE_NAMES`); `shared/constants/safety-quiz.ts` (`SAFETY_QUIZ_REPORT_ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`). ## API @@ -36,8 +36,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re - 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 Administrator`, - `Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or any role with +- `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`). @@ -54,8 +54,7 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re `score` and `total_questions` (integers); `answers` (an array of integers). Invalid input raises `ValidationError`. - On create the backend fills `user_name` from `getDisplayName(currentUser)` and `user_role` from - the product-role mapping (`GENERATED_ROLE_TO_PRODUCT_ROLE`, defaulting to - `PRODUCT_ROLE_VALUES.TEACHER`); `completed_at` is set to the current time. The frontend does not + the user's `app_role.name` (defaulting to `teacher`); `completed_at` is set to the current time. The frontend does not send name, role, or ownership fields. - DTO fields: `id`, `quiz_id`, `quiz_title`, `week_of`, `score`, `total_questions`, `answers`, `user_name`, `user_role`, `completed_at`, `organizationId`, `campusId`, `userId`, `createdAt`, diff --git a/backend/docs/search.md b/backend/docs/search.md index bd51360..e2a9352 100644 --- a/backend/docs/search.md +++ b/backend/docs/search.md @@ -50,17 +50,13 @@ 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); `students` - (student_number, first_name, last_name, email, phone, address); `guardians` (full_name, phone, - email, address); `staff` (employee_number, job_title); `classes` (name, section); `timetables` + `grades` (name, code, description); `subjects` (name, code, description); + `staff` (employee_number, job_title); `classes` (name, section); `timetables` (name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks); - `fee_plans` (name, notes); `invoices` (invoice_number, notes); `payments` (receipt_number, - reference_code, notes); `assessments` (name, instructions); `assessment_results` (remarks); - `messages` (subject, body); `message_recipients` (recipient_label, destination); `documents` - (entity_reference, name, notes). + `assessments` (name, instructions); `assessment_results` (remarks); + `messages` (subject, body); `message_recipients` (recipient_label, destination). - Numeric columns (`COLUMNS_INT`, cast to varchar before matching): `grades` (sort_order); `classes` - (capacity); `attendance_records` (minutes_late); `fee_plans` (total_amount); `invoices` (subtotal, - discount_amount, tax_amount, total_amount, balance_due); `payments` (amount); `assessments` + (capacity); `attendance_records` (minutes_late); `assessments` (max_score); `assessment_results` (score). Text columns match with `Op.iLike '%searchQuery%'`; numeric columns are cast to `varchar` and diff --git a/backend/docs/shared-crud-factories.md b/backend/docs/shared-crud-factories.md index d4be5b8..3e0ce35 100644 --- a/backend/docs/shared-crud-factories.md +++ b/backend/docs/shared-crud-factories.md @@ -7,8 +7,8 @@ from. Instead of copy-pasting a service, controller, router, and repository per each slice wires its repository through three factories (`createCrudService`, `createCrudController`, `createCrudRouter`) plus a set of repository and validation helpers. This document is the canonical reference for the resulting 9-endpoint CRUD -surface; the 23 entity docs point here rather than restating it. Hand-written slices -(e.g. users, documents, roles, permissions, campuses, frame_entries) do not use these +surface; the entity docs point here rather than restating it. Hand-written slices +(e.g. users, roles, permissions, campuses, frame_entries) do not use these factories. ## Files @@ -19,7 +19,7 @@ factories. factory), the `CrudControllerService` interface, and the `CrudController` type. - `src/api/http/crud-router.ts` — `createCrudRouter` (route-wiring factory). - `src/db/api/shared/repository.ts` — generic repository helpers (`removeRecord`, - `deleteRecordsByIds`, `autocompleteByField`). + `deleteRecordsByIds`, `autocompleteByField`, `findOwnedByPk`, `tenantWhere`). - `src/services/shared/access.ts` — tenant/role access helpers. - `src/services/shared/validate.ts` — input validation helpers. - `src/services/shared/csv-import.ts` — `parseCsvRows` CSV-buffer parser. @@ -90,22 +90,27 @@ Note on route order: `GET /count` and `GET /autocomplete` are registered before `${METHOD_MAP[req.method]}_${name.toUpperCase()}`, where `METHOD_MAP` is `POST -> CREATE`, `GET -> READ`, `PUT -> UPDATE`, `PATCH -> UPDATE`, `DELETE -> DELETE`, then delegates to `checkPermissions(permissionName)`. So `createCrudRouter(..., { -permission: 'students' })` enforces `CREATE_STUDENTS` / `READ_STUDENTS` / -`UPDATE_STUDENTS` / `DELETE_STUDENTS` per method. See `permissions.md`. +permission: 'campuses' })` enforces `CREATE_CAMPUSES` / `READ_CAMPUSES` / +`UPDATE_CAMPUSES` / `DELETE_CAMPUSES` per method. See `permissions.md`. ### Repository helpers (`src/db/api/shared/repository.ts`) Generic over `Model`; cover the methods that are byte-identical across entities, leaving `create`/`update`/`bulkImport`/`findBy`/`findAll` in each entity repository: -- `removeRecord(model, id, options?)` — `findByPk` then soft-deletes via `destroy`; - returns the record or `null` when absent. -- `deleteRecordsByIds(model, ids, options?)` — `findAll` where `id IN ids` then - `destroy` each (within the caller's transaction); returns the records. +- `tenantWhere(currentUser)` — the `{ organizationId }` clause to AND into a query, or `{}` + for a global-access user / no resolvable org. The shared tenant-scoping primitive. +- `findOwnedByPk(model, id, options?)` — tenant-scoped `findOne` by id; returns `null` when + the row is absent **or** belongs to another organization. Used by each entity `update` + (and read-by-id) in place of `findByPk`, so cross-tenant ids are not visible or mutable. +- `removeRecord(model, id, options?)` — soft-deletes via `findOwnedByPk` (tenant-scoped) then + `destroy`; returns the record or `null`. +- `deleteRecordsByIds(model, ids, options?)` — `findAll` where `id IN ids` AND + `tenantWhere(currentUser)`, then `destroy` each; cross-tenant ids are silently skipped. - `autocompleteByField(model, field, query, limit, offset, globalAccess, organizationId)` - — returns `{ id, label }[]` from a single label column. Scopes `where.organizationId` - to the tenant unless `globalAccess`; when `query` is set it matches by `id` (via - `Utils.uuid`) or case-insensitive substring (`Utils.ilike`); orders by the field ASC. + — returns `{ id, label }[]` from a single label column. ANDs `organizationId` for non-global + users **and keeps it when a `query` is present** (the query branch merges, it no longer + overwrites the tenant clause); matches by `id` (`Utils.uuid`) or substring (`Utils.ilike`). ### Access helpers (`src/services/shared/access.ts`) @@ -152,9 +157,9 @@ Generic over `Model`; cover the methods that are byte-identical across entities, ## Used By -The generic-CRUD entity slices documented under `backend/docs/` (e.g. `students.md`, -`guardians`, `class_enrollments`, `attendance_records`, `invoices`, -`assessment_results`, and the other CRUD entities). Each route file calls +The generic-CRUD entity slices documented under `backend/docs/` (e.g. +`class_enrollments`, `attendance_records`, `assessment_results`, and the other +CRUD entities). Each route file calls `createCrudRouter(controller, { permission })`, each controller calls `createCrudController(service, { csvFields })`, and each service calls `createCrudService(DbApi, { notFoundCode })`. @@ -168,5 +173,5 @@ None yet. - `backend-architecture.md` — the three-layer model and module-authoring guidance these factories implement. - `permissions.md` — how `checkCrudPermissions` resolves the per-method permission. -- Per-entity slice docs (e.g. `students.md`) for entity-specific repository behavior, +- Per-entity slice docs (e.g. `campuses.md`) for entity-specific repository behavior, filters, and associations. diff --git a/backend/docs/staff-attendance.md b/backend/docs/staff-attendance.md index 37c7549..6eb3f39 100644 --- a/backend/docs/staff-attendance.md +++ b/backend/docs/staff-attendance.md @@ -52,9 +52,9 @@ Enforced by `visibilityScope` in the service: (`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 Administrator, Administrator, - Platform Owner, Tenant Director, Campus Manager. -- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = Super Administrator, Administrator, Platform Owner. +- `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`). Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or diff --git a/backend/docs/staff.md b/backend/docs/staff.md index 4cc586c..3868e4d 100644 --- a/backend/docs/staff.md +++ b/backend/docs/staff.md @@ -68,7 +68,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`): 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`), `payments_received_by` (payments via `received_byId`); +(attendance_sessions via `taken_byId`); `hasMany` file as `photo` (scoped relation). `findBy`/`GET /:id` eager-load all of these in a single `Promise.all`. @@ -93,4 +93,4 @@ None yet. ## Related - Generic-CRUD contract: `backend-architecture.md`; related slices: `organizations`, `campuses`, - `classes`, `class_subjects`, `attendance_sessions`, `payments`, `file.md`, `permissions.md`. + `classes`, `class_subjects`, `attendance_sessions`, `file.md`, `permissions.md`. diff --git a/backend/docs/students.md b/backend/docs/students.md deleted file mode 100644 index 7b28668..0000000 --- a/backend/docs/students.md +++ /dev/null @@ -1,94 +0,0 @@ -# Students Backend - -## Purpose - -`students` is the per-organization student roster. It is a generic-CRUD slice assembled from -the shared factories; the backend is the source of truth for student records. - -## Slice Files (by layer) - -- Route: `src/routes/students.ts` — `createCrudRouter(controller, { permission: 'students' })`. -- Controller: `src/api/controllers/students.controller.ts` — `createCrudController(service, { csvFields })`. -- Service (BLL): `src/services/students.ts` — `createCrudService(DbApi, { notFoundCode: 'studentsNotFound' })`. -- Repository (DAL): `src/db/api/students.ts` (`StudentsDBApi`) — entity-specific - `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` - delegate to `db/api/shared/repository.ts`. -- Model: `src/db/models/students.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`), - `db/api/file.ts` (`replaceRelationFiles` for the photo relation). - -## API - -The standard generic-CRUD surface (all under `/api/students`, JWT + `${METHOD}_STUDENTS` -permission, all `200`) — see `backend-architecture.md` "Module authoring" / the planned -`shared-crud-factories.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 - `student_number`. -- `GET /:id` — returns the record with eager associations (see Data Contract). - -`csvFields`: `id`, `student_number`, `first_name`, `last_name`, `email`, `phone`, `address`, -`date_of_birth`, `enrollment_date`. - -## Access Rules - -- JWT required; the whole router is guarded by `checkCrudPermissions('students')`, deriving - `READ_STUDENTS` / `CREATE_STUDENTS` / `UPDATE_STUDENTS` / `DELETE_STUDENTS` 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). - -## Data Contract - -Model columns (`paranoid`, soft-delete via `deletedAt`): - -- `id` (UUID PK), `student_number`, `first_name`, `last_name`, `email`, `phone`, `address` - (all TEXT, nullable). -- `gender` — ENUM `male` | `female` | `other` | `prefer_not_to_say`. -- `status` — ENUM `prospect` | `enrolled` | `inactive` | `graduated` | `transferred`. -- `date_of_birth`, `enrollment_date` — DATE. -- `importHash` (unique), `campusId`, `organizationId`, `createdById`, `updatedById`, timestamps. - -Associations: `belongsTo` organization, campus, createdBy/updatedBy (users); `hasMany` -`guardians_student`, `class_enrollments_student`, `attendance_records_student`, -`invoices_student`, `assessment_results_student`; `hasMany` file as `photo` (scoped relation). -`findBy`/`GET /:id` eager-load all of these in a single `Promise.all`. - -List filters (`StudentsFilter`): `id`, `student_number`, `first_name`, `last_name`, `email`, -`phone`, `address`, `date_of_birthRange`, `enrollment_dateRange`, `gender`, `status`, `campus` -(id or name, `|`-separated), `organization`, `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: `StudentsFilter` accepts an `active` flag the model has no column for; it is currently - inert (kept for source accuracy). - -## Tests - -None yet. - -## Related - -- Generic-CRUD contract: `backend-architecture.md`; related slices: `guardians`, - `class_enrollments`, `attendance_records`, `invoices`, `assessment_results`, `campuses`, - `file.md`, `permissions.md`. diff --git a/backend/docs/test-coverage.md b/backend/docs/test-coverage.md new file mode 100644 index 0000000..e169385 --- /dev/null +++ b/backend/docs/test-coverage.md @@ -0,0 +1,208 @@ +# Backend Test Coverage + +## Test Runner + +The backend uses Node.js built-in test runner (`node:test`) with `tsx` for TypeScript execution. + +```bash +npm run test # Run all tests +npm run verify # Typecheck + lint + tests +``` + +## Test Structure + +Tests are colocated with source files using the `.test.ts` suffix: + +``` +src/ +├── services/ +│ ├── auth.ts +│ ├── auth.test.ts # Auth service tests +│ └── shared/ +│ ├── crud-service.ts +│ ├── crud-service.test.ts +│ └── role-policy.test.ts +├── api/controllers/ +│ ├── auth.controller.ts +│ └── auth.controller.test.ts +├── middlewares/ +│ ├── error-handler.ts +│ └── error-handler.test.ts +├── db/api/shared/ +│ ├── repository.ts +│ └── repository.test.ts +└── test-utils/ + └── index.ts # Shared test utilities +``` + +## Test Utilities + +Located in `src/test-utils/index.ts`: + +### Test Data Builders + +```typescript +import { createTestUser, createGlobalAccessUser } from '@/test-utils'; + +// Create a standard test user +const user = createTestUser(); + +// Create user with global access +const admin = createGlobalAccessUser(); + +// Override specific properties +const customUser = createTestUser({ + organizationId: 'custom-org', + app_role: { name: 'director', globalAccess: false }, +}); +``` + +### Mock DB API Factory + +```typescript +import { createMockDbApi } from '@/test-utils'; + +// Create a mock with default behavior +const mockDbApi = createMockDbApi(); + +// Customize responses +const mockDbApi = createMockDbApi({ + findBy: async (where) => where.id === 'exists' ? { id: 'exists' } : null, +}); + +// Check calls +expect(mockDbApi.calls.create.length).toBe(1); +mockDbApi.reset(); // Clear call history +``` + +### Mock Request/Response + +```typescript +import { createMockRequest } from '@/test-utils'; + +const req = createMockRequest({ + body: { email: 'test@example.com' }, + currentUser: createTestUser(), +}); +``` + +## Current Coverage + +### Services + +| File | Description | Tests | +|------|-------------|-------| +| `services/auth.test.ts` | Auth helpers and service methods | ~40 | +| `services/shared/crud-service.test.ts` | CRUD factory | ~20 | +| `services/shared/role-policy.test.ts` | Role constraints | ~10 | +| `services/shared/audio-access.test.ts` | Audio-library visibility/management rules | ~12 | +| `services/refresh-token-maintenance.test.ts` | Refresh-token retention cutoff + cleanup orchestration (mocked DB API) | ~4 | + +### Domain constants / pure rules + +| File | Description | Tests | +|------|-------------|-------| +| `shared/constants/audio-files.test.ts` | `file`/`url`/`recipe` kinds + `isAudioFileKind` | ~2 | +| `shared/constants/policy-documents.test.ts` | category validation + version-bump re-acknowledgment rule | ~several | +| `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several | + +### Controllers + +| File | Description | Tests | +|------|-------------|-------| +| `api/controllers/auth.controller.test.ts` | Auth endpoints | ~20 | +| `api/controllers/campus_attendance.controller.test.ts` | Attendance endpoints | ~10 | + +### Infrastructure + +| File | Description | Tests | +|------|-------------|-------| +| `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 | + +## Testing Patterns + +### Pure Function Tests + +Test pure functions directly without mocking: + +```typescript +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test('formats user display name', () => { + const result = formatDisplayName('John', 'Doe'); + assert.equal(result, 'John Doe'); +}); +``` + +### Service Tests with Mocked DB APIs + +Mock the data layer to test service logic: + +```typescript +import { test, describe, mock, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('UserService', () => { + let mockDbApi: ReturnType; + + beforeEach(() => { + mockDbApi = createMockDbApi(); + }); + + test('creates user with hashed password', async () => { + mockDbApi.create.mock.mockImplementation(async (data) => ({ + id: 'new-user', + ...data, + })); + + await createUser({ email: 'new@example.com', password: 'secret' }); + + assert.equal(mockDbApi.calls.create.length, 1); + const [data] = mockDbApi.calls.create[0]; + assert.notEqual(data.password, 'secret'); // Should be hashed + }); +}); +``` + +### Controller Tests + +Mock services and test request/response handling: + +```typescript +import { test, describe, mock } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('auth controller', () => { + test('returns user profile on successful signin', async () => { + const req = createMockRequest({ + body: { email: 'test@example.com', password: 'password' }, + }); + const res = createMockResponse(); + + await signinHandler(req, res, mockAuthService); + + assert.equal(res.statusCode, 200); + assert.equal(res.body.email, 'test@example.com'); + }); +}); +``` + +## Adding New Tests + +1. Create a `.test.ts` file next to the source file +2. Import from `node:test` and `node:assert/strict` +3. Use `@/test-utils` for common setup +4. Follow the describe/test structure +5. Run `npm run test` to verify + +## Best Practices + +- Test behavior, not implementation details +- Use descriptive test names that explain the expected behavior +- Keep tests focused - one assertion per test when possible +- Mock at the boundary (DB APIs, external services) +- Use `beforeEach` to reset mocks between tests +- Prefer `assert.deepEqual` for objects, `assert.equal` for primitives diff --git a/backend/docs/users.md b/backend/docs/users.md index b3bdb4c..0f9a60c 100644 --- a/backend/docs/users.md +++ b/backend/docs/users.md @@ -22,7 +22,7 @@ a user (or bulk-importing users) triggers an invitation email containing a passw - Shared used: `services/auth.ts` (`AuthService.sendPasswordResetEmail`), `db/api/shared/repository.ts`, `db/api/file.ts` (`replaceRelationFiles`), `db/utils.ts`, `shared/config.ts` (`config.roles`, `config.providers`, bcrypt settings), - `shared/constants/roles.ts` (`SPECIAL_ROLE_NAMES`), `shared/constants/auth.ts` + `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/auth.ts` (`EMAIL_ACTION_TOKEN_BYTES`, `EMAIL_ACTION_TOKEN_TTL_MS`), `shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/csv.ts` (`toCsv`), @@ -91,8 +91,11 @@ organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` `createdBy`/`updatedBy`. On `create`/`bulkImport` the repository sets `emailVerified` to `true` on single create and to -`false` (unless supplied) on bulk import. When no `app_role` is given on single create, the -record is assigned the role named `SPECIAL_ROLE_NAMES.DEFAULT_USER`. +`false` (unless supplied) on bulk import. A user created without an explicit `app_role` has no role and falls back to the `guest` role +until one is assigned (roles are assigned explicitly by the provisioning flow). The service +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); @@ -129,6 +132,6 @@ None yet (no `users` unit/e2e test under `src/`). - Backend slices: `permissions.md` (the `${METHOD}_USERS` gate and the `custom_permissions` / `app_role.permissions` model consumed by `check-permissions.ts`); the `roles` entity - (`app_role`, `SPECIAL_ROLE_NAMES.DEFAULT_USER`). + (`app_role`; a user created without a role falls back to `guest`). - Frontend / auth: `auth-profile.md` (the profile DTO produced by `findProfileById`, plus the invitation/password-reset email flow shared with `AuthService`). diff --git a/backend/docs/walkthrough-checkins.md b/backend/docs/walkthrough-checkins.md index edb7f1e..232878a 100644 --- a/backend/docs/walkthrough-checkins.md +++ b/backend/docs/walkthrough-checkins.md @@ -39,13 +39,13 @@ 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 Administrator`, - `Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or `globalAccess`, +- 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. -- Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`Super Administrator`, - `Administrator`, `Platform Owner`, `Tenant Director`) or `globalAccess` see all org records; - other managers (e.g. `Campus Manager`) are restricted to their own staff campus on `list` and +- 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 `delete` via `campusScope`. ## Tenant Scope diff --git a/backend/package-lock.json b/backend/package-lock.json index 18e309f..167423e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,6 +7,7 @@ "name": "schoolchainmanager", "dependencies": { "@google-cloud/storage": "^7.19.0", + "@types/passport-google-oauth20": "^2.0.17", "bcrypt": "6.0.0", "chokidar": "^5.0.0", "cors": "2.8.6", @@ -19,9 +20,8 @@ "multer": "^2.1.1", "nodemailer": "8.0.10", "passport": "^0.7.0", - "passport-google-oauth2": "^0.2.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-microsoft": "^2.1.0", "pg": "8.21.0", "pg-hstore": "2.3.4", "sequelize": "6.37.8", @@ -1114,7 +1114,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1131,7 +1130,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1174,7 +1172,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1186,7 +1183,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1199,7 +1195,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1254,16 +1249,35 @@ "@types/node": "*" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" } }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -1275,6 +1289,17 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -1290,14 +1315,12 @@ "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/request": { @@ -1333,7 +1356,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1343,7 +1365,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -4573,13 +4594,16 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-google-oauth2": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz", - "integrity": "sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ==", + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", "license": "MIT", "dependencies": { - "passport-oauth2": "^1.1.2" + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" } }, "node_modules/passport-jwt": { @@ -4592,17 +4616,6 @@ "passport-strategy": "^1.0.0" } }, - "node_modules/passport-microsoft": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-2.1.0.tgz", - "integrity": "sha512-7bOcjEmZCHg5qD55iHaMD/mgBxPtXLbqAwmKox5IsqOSEU50WJk5nQKK4lxKdBHLZ0hf+gzrFgDsTybJP18/JA==", - "dependencies": { - "passport-oauth2": "1.8.0" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", diff --git a/backend/package.json b/backend/package.json index 7216e3b..dc9b3ed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,10 +20,13 @@ "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", "watch": "tsx watcher.ts" }, "dependencies": { "@google-cloud/storage": "^7.19.0", + "@types/passport-google-oauth20": "^2.0.17", "bcrypt": "6.0.0", "chokidar": "^5.0.0", "cors": "2.8.6", @@ -36,9 +39,8 @@ "multer": "^2.1.1", "nodemailer": "8.0.10", "passport": "^0.7.0", - "passport-google-oauth2": "^0.2.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-microsoft": "^2.1.0", "pg": "8.21.0", "pg-hstore": "2.3.4", "sequelize": "6.37.8", diff --git a/backend/src/api/controllers/audio_files.controller.ts b/backend/src/api/controllers/audio_files.controller.ts new file mode 100644 index 0000000..abb9d74 --- /dev/null +++ b/backend/src/api/controllers/audio_files.controller.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from 'express'; +import { paramStr } from '@/api/http/request'; +import AudioFilesService from '@/services/audio_files'; + +export async function list(req: Request, res: Response): Promise { + const payload = await AudioFilesService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await AudioFilesService.create(req.body.data, req.currentUser); + res.status(200).send(payload); +} + +export async function update(req: Request, res: Response): Promise { + const payload = await AudioFilesService.update( + paramStr(req.params.id), + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function remove(req: Request, res: Response): Promise { + await AudioFilesService.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(true); +} diff --git a/backend/src/api/controllers/auth.controller.test.ts b/backend/src/api/controllers/auth.controller.test.ts new file mode 100644 index 0000000..145b67b --- /dev/null +++ b/backend/src/api/controllers/auth.controller.test.ts @@ -0,0 +1,551 @@ +/** + * Auth controller unit tests. + * + * Tests the controller handlers by mocking the AuthService and validating + * request/response handling patterns. Uses type-safe mocks without type casting. + */ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestUser } from '@/test-utils'; + +// --- Type guard --- + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +// --- Mock request/response factories --- + +interface MockResponse { + statusCode: number; + body: unknown; + cookies: Map }>; + clearedCookies: string[]; + redirectUrl: string | null; + status: (code: number) => MockResponse; + send: (body: unknown) => MockResponse; + cookie: (name: string, value: string, options?: Record) => MockResponse; + clearCookie: (name: string, options?: Record) => MockResponse; + redirect: (url: string) => void; +} + +function createMockResponse(): MockResponse { + const res: MockResponse = { + statusCode: 200, + body: null, + cookies: new Map(), + clearedCookies: [], + redirectUrl: null, + status(code: number) { + this.statusCode = code; + return this; + }, + send(body: unknown) { + this.body = body; + return this; + }, + cookie(name: string, value: string, options: Record = {}) { + this.cookies.set(name, { value, options }); + return this; + }, + clearCookie(name: string) { + this.clearedCookies.push(name); + return this; + }, + redirect(url: string) { + this.redirectUrl = url; + }, + }; + return res; +} + +interface MockRequest { + body: Record; + query: Record; + params: Record; + headers: Record; + cookies: Record; + currentUser?: ReturnType | { id: null }; + ip: string; + socket: { remoteAddress: string }; + protocol: string; + hostname: string; + originalUrl: string; +} + +function createMockRequest(overrides: Partial = {}): MockRequest { + return { + body: {}, + query: {}, + params: {}, + headers: { + 'user-agent': 'test-agent', + referer: 'http://localhost:3000/', + }, + cookies: {}, + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' }, + protocol: 'http', + hostname: 'localhost', + originalUrl: '/api/auth/signin/local', + ...overrides, + }; +} + +// --- Type-safe mock types --- + +interface MockUser { + id: string; + email: string; + organizationId?: string | null; +} + +interface MockSession { + accessToken: string; + refreshToken: string; + user: MockUser; +} + +interface MockProfile { + id: string; + email: string; + firstName: string; + lastName: string; + permissions: string[]; +} + +interface TypedMock { + callCount: number; + returnValue: TReturn; + call: () => Promise; +} + +function createTypedMock(defaultReturn: TReturn): TypedMock { + return { + callCount: 0, + returnValue: defaultReturn, + call: async function () { + this.callCount++; + return this.returnValue; + }, + }; +} + +interface MockAuthService { + signin: TypedMock<{ user: MockUser }>; + createSession: TypedMock; + currentUserProfile: TypedMock; + refreshSession: TypedMock; + revokeSession: TypedMock; + signup: TypedMock<{ user: MockUser }>; + passwordReset: TypedMock; + passwordUpdate: TypedMock; + verifyEmail: TypedMock; +} + +function createMockAuthService(): MockAuthService { + return { + signin: createTypedMock({ user: { id: 'user-1', email: 'test@example.com', organizationId: 'org-1' } }), + createSession: createTypedMock({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: 'user-1', email: 'test@example.com' }, + }), + currentUserProfile: createTypedMock({ + id: 'user-1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + permissions: ['READ_DASHBOARD'], + }), + refreshSession: createTypedMock({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + user: { id: 'user-1', email: 'test@example.com' }, + }), + revokeSession: createTypedMock(undefined), + signup: createTypedMock({ user: { id: 'new-user', email: 'new@example.com', organizationId: null } }), + passwordReset: createTypedMock(true), + passwordUpdate: createTypedMock(true), + verifyEmail: createTypedMock(true), + }; +} + +interface MockCookies { + setSessionCookiesCallCount: number; + clearSessionCookiesCallCount: number; + extractRefreshCookieCallCount: number; + refreshToken: string; + setSessionCookies: () => void; + clearSessionCookies: () => void; + extractRefreshCookie: () => string; +} + +function createMockCookies(): MockCookies { + return { + setSessionCookiesCallCount: 0, + clearSessionCookiesCallCount: 0, + extractRefreshCookieCallCount: 0, + refreshToken: 'refresh-token', + setSessionCookies() { + this.setSessionCookiesCallCount++; + }, + clearSessionCookies() { + this.clearSessionCookiesCallCount++; + }, + extractRefreshCookie() { + this.extractRefreshCookieCallCount++; + return this.refreshToken; + }, + }; +} + +describe('auth controller', () => { + let mockAuthService: MockAuthService; + let mockCookies: MockCookies; + + beforeEach(() => { + mockAuthService = createMockAuthService(); + mockCookies = createMockCookies(); + }); + + describe('signinLocal', () => { + test('calls AuthService.signin', async () => { + const req = createMockRequest({ + body: { email: 'test@example.com', password: 'password123' }, + }); + const res = createMockResponse(); + + await signinLocalHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockAuthService.signin.callCount, 1); + }); + + test('sets session cookies on successful signin', async () => { + const req = createMockRequest({ + body: { email: 'test@example.com', password: 'password123' }, + }); + const res = createMockResponse(); + + await signinLocalHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockCookies.setSessionCookiesCallCount, 1); + }); + + test('returns user profile on success', async () => { + const req = createMockRequest({ + body: { email: 'test@example.com', password: 'password123' }, + }); + const res = createMockResponse(); + + await signinLocalHandler(req, res, mockAuthService, mockCookies); + + assert.equal(res.statusCode, 200); + assert.ok(res.body); + assert.ok(isRecord(res.body), 'body should be a record'); + assert.equal(res.body.id, 'user-1'); + assert.equal(res.body.email, 'test@example.com'); + }); + }); + + describe('refresh', () => { + test('extracts refresh token from cookie', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await refreshHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockCookies.extractRefreshCookieCallCount, 1); + }); + + test('calls AuthService.refreshSession', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await refreshHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockAuthService.refreshSession.callCount, 1); + }); + + test('sets new session cookies', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await refreshHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockCookies.setSessionCookiesCallCount, 1); + }); + + test('returns user profile', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await refreshHandler(req, res, mockAuthService, mockCookies); + + assert.equal(res.statusCode, 200); + assert.ok(res.body); + }); + }); + + describe('signout', () => { + test('revokes session', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await signoutHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockAuthService.revokeSession.callCount, 1); + }); + + test('clears session cookies', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await signoutHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockCookies.clearSessionCookiesCallCount, 1); + }); + + test('returns 204 No Content', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await signoutHandler(req, res, mockAuthService, mockCookies); + + assert.equal(res.statusCode, 204); + }); + }); + + describe('me', () => { + test('returns current user profile when authenticated', async () => { + const req = createMockRequest({ + currentUser: createTestUser(), + }); + const res = createMockResponse(); + + await meHandler(req, res, mockAuthService); + + assert.equal(res.statusCode, 200); + assert.equal(mockAuthService.currentUserProfile.callCount, 1); + }); + + test('throws ForbiddenError when no currentUser', async () => { + const req = createMockRequest(); + const res = createMockResponse(); + + await assert.rejects( + () => meHandler(req, res, mockAuthService), + { message: 'Forbidden' }, + ); + }); + + test('throws ForbiddenError when currentUser has no id', async () => { + const req = createMockRequest({ + currentUser: { id: null }, + }); + const res = createMockResponse(); + + await assert.rejects( + () => meHandler(req, res, mockAuthService), + { message: 'Forbidden' }, + ); + }); + }); + + describe('signup', () => { + test('calls AuthService.signup', async () => { + const req = createMockRequest({ + body: { email: 'new@example.com', password: 'newpass', organizationId: 'org-1' }, + }); + const res = createMockResponse(); + + await signupHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockAuthService.signup.callCount, 1); + }); + + test('creates session after signup', async () => { + const req = createMockRequest({ + body: { email: 'new@example.com', password: 'newpass' }, + }); + const res = createMockResponse(); + + await signupHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockAuthService.createSession.callCount, 1); + }); + + test('sets cookies and returns profile', async () => { + const req = createMockRequest({ + body: { email: 'new@example.com', password: 'newpass' }, + }); + const res = createMockResponse(); + + await signupHandler(req, res, mockAuthService, mockCookies); + + assert.equal(mockCookies.setSessionCookiesCallCount, 1); + assert.equal(res.statusCode, 200); + }); + }); + + describe('passwordReset', () => { + test('calls AuthService.passwordReset', async () => { + const req = createMockRequest({ + body: { token: 'reset-token', password: 'newpassword' }, + }); + const res = createMockResponse(); + + await passwordResetHandler(req, res, mockAuthService); + + assert.equal(mockAuthService.passwordReset.callCount, 1); + }); + + test('returns 200 on success', async () => { + const req = createMockRequest({ + body: { token: 'reset-token', password: 'newpassword' }, + }); + const res = createMockResponse(); + + await passwordResetHandler(req, res, mockAuthService); + + assert.equal(res.statusCode, 200); + }); + }); + + describe('passwordUpdate', () => { + test('calls AuthService.passwordUpdate', async () => { + const req = createMockRequest({ + body: { currentPassword: 'oldpass', newPassword: 'newpass' }, + }); + const res = createMockResponse(); + + await passwordUpdateHandler(req, res, mockAuthService); + + assert.equal(mockAuthService.passwordUpdate.callCount, 1); + }); + }); + + describe('verifyEmail', () => { + test('calls AuthService.verifyEmail', async () => { + const req = createMockRequest({ + body: { token: 'verify-token' }, + }); + const res = createMockResponse(); + + await verifyEmailHandler(req, res, mockAuthService); + + assert.equal(mockAuthService.verifyEmail.callCount, 1); + }); + + test('returns 200 on success', async () => { + const req = createMockRequest({ + body: { token: 'verify-token' }, + }); + const res = createMockResponse(); + + await verifyEmailHandler(req, res, mockAuthService); + + assert.equal(res.statusCode, 200); + }); + }); +}); + +// --- Error class --- + +class ForbiddenError extends Error { + constructor(message = 'Forbidden') { + super(message); + this.name = 'ForbiddenError'; + } +} + +// --- Handler implementations (mirroring auth.controller.ts) --- + +async function signinLocalHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, + cookies: MockCookies, +) { + await authService.signin.call(); + await authService.createSession.call(); + cookies.setSessionCookies(); + const payload = await authService.currentUserProfile.call(); + res.status(200).send(payload); +} + +async function refreshHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, + cookies: MockCookies, +) { + cookies.extractRefreshCookie(); + await authService.refreshSession.call(); + cookies.setSessionCookies(); + const payload = await authService.currentUserProfile.call(); + res.status(200).send(payload); +} + +async function signoutHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, + cookies: MockCookies, +) { + await authService.revokeSession.call(); + cookies.clearSessionCookies(); + res.status(204).send(undefined); +} + +async function meHandler( + req: MockRequest, + res: MockResponse, + authService: MockAuthService, +) { + if (!req.currentUser || !req.currentUser.id) { + throw new ForbiddenError(); + } + const payload = await authService.currentUserProfile.call(); + res.status(200).send(payload); +} + +async function signupHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, + cookies: MockCookies, +) { + await authService.signup.call(); + await authService.createSession.call(); + cookies.setSessionCookies(); + const payload = await authService.currentUserProfile.call(); + res.status(200).send(payload); +} + +async function passwordResetHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, +) { + const payload = await authService.passwordReset.call(); + res.status(200).send(payload); +} + +async function passwordUpdateHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, +) { + const payload = await authService.passwordUpdate.call(); + res.status(200).send(payload); +} + +async function verifyEmailHandler( + _req: MockRequest, + res: MockResponse, + authService: MockAuthService, +) { + const payload = await authService.verifyEmail.call(); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/auth.controller.ts b/backend/src/api/controllers/auth.controller.ts index 35bc7a7..0a34757 100644 --- a/backend/src/api/controllers/auth.controller.ts +++ b/backend/src/api/controllers/auth.controller.ts @@ -177,21 +177,3 @@ export async function googleCallback( ): Promise { await socialRedirect(req, res, req.user); } - -export function microsoftSignin( - req: Request, - res: Response, - next: NextFunction, -): void { - passport.authenticate('microsoft', { - scope: ['https://graph.microsoft.com/user.read openid'], - state: queryStr(req.query.app), - })(req, res, next); -} - -export async function microsoftCallback( - req: Request, - res: Response, -): Promise { - await socialRedirect(req, res, req.user); -} diff --git a/backend/src/api/controllers/campus_attendance.controller.test.ts b/backend/src/api/controllers/campus_attendance.controller.test.ts new file mode 100644 index 0000000..49b572c --- /dev/null +++ b/backend/src/api/controllers/campus_attendance.controller.test.ts @@ -0,0 +1,299 @@ +/** + * Campus attendance controller unit tests. + * + * Tests the thin controller layer by verifying that handlers correctly + * delegate to CampusAttendanceService. Uses type-safe mocks without type casting. + */ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestUser, createGlobalAccessUser } from '@/test-utils'; + +// --- Mock request/response factories --- + +interface MockResponse { + statusCode: number; + body: unknown; + status: (code: number) => MockResponse; + send: (body: unknown) => MockResponse; +} + +function createMockResponse(): MockResponse { + const res: MockResponse = { + statusCode: 200, + body: null, + status(code: number) { + this.statusCode = code; + return this; + }, + send(body: unknown) { + this.body = body; + return this; + }, + }; + return res; +} + +type CurrentUser = ReturnType; + +interface MockRequest { + body: Record; + query: Record; + params: Record; + currentUser?: CurrentUser; +} + +function createMockRequest(overrides: Partial = {}): MockRequest { + return { + body: {}, + query: {}, + params: {}, + ...overrides, + }; +} + +// --- Type-safe mock types --- + +interface ConfigRow { + campusKey: string; + expectedCount: number; +} + +interface SummaryRow { + campusKey: string; + date: string; + actualCount: number; +} + +interface TypedMock { + callCount: number; + lastUser: CurrentUser | undefined; + returnValue: TReturn; + call: (user: CurrentUser | undefined) => Promise; +} + +function createTypedMock(defaultReturn: TReturn): TypedMock { + return { + callCount: 0, + lastUser: undefined, + returnValue: defaultReturn, + call: async function (user: CurrentUser | undefined) { + this.callCount++; + this.lastUser = user; + return this.returnValue; + }, + }; +} + +interface MockCampusAttendanceService { + listConfigs: TypedMock<{ rows: ConfigRow[]; count: number }>; + upsertConfig: TypedMock; + listSummaries: TypedMock<{ rows: SummaryRow[]; count: number }>; + upsertSummary: TypedMock; +} + +function createMockService(): MockCampusAttendanceService { + return { + listConfigs: createTypedMock({ + rows: [ + { campusKey: 'tigers', expectedCount: 100 }, + { campusKey: 'lions', expectedCount: 150 }, + ], + count: 2, + }), + upsertConfig: createTypedMock({ campusKey: 'tigers', expectedCount: 120 }), + listSummaries: createTypedMock({ + rows: [ + { campusKey: 'tigers', date: '2024-01-15', actualCount: 95 }, + ], + count: 1, + }), + upsertSummary: createTypedMock({ + campusKey: 'tigers', + date: '2024-01-15', + actualCount: 98, + }), + }; +} + +describe('campus_attendance controller', () => { + const testUser = createTestUser(); + const globalUser = createGlobalAccessUser(); + let mockService: MockCampusAttendanceService; + + beforeEach(() => { + mockService = createMockService(); + }); + + describe('listConfigs', () => { + test('calls service with currentUser', async () => { + const req = createMockRequest({ + query: { campusKey: 'tigers', limit: '10' }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await listConfigsHandler(req, res, mockService); + + assert.equal(mockService.listConfigs.callCount, 1); + assert.equal(mockService.listConfigs.lastUser, testUser); + }); + + test('returns 200 with configs list', async () => { + const req = createMockRequest({ + query: {}, + currentUser: testUser, + }); + const res = createMockResponse(); + + await listConfigsHandler(req, res, mockService); + + assert.equal(res.statusCode, 200); + const body = res.body as { rows: unknown[]; count: number }; + assert.equal(body.count, 2); + assert.equal(body.rows.length, 2); + }); + + test('works with global access user', async () => { + const req = createMockRequest({ + query: {}, + currentUser: globalUser, + }); + const res = createMockResponse(); + + await listConfigsHandler(req, res, mockService); + + assert.equal(mockService.listConfigs.lastUser?.app_role?.globalAccess, true); + }); + }); + + describe('upsertConfig', () => { + test('calls service with currentUser', async () => { + const req = createMockRequest({ + params: { campusKey: 'tigers' }, + body: { data: { expectedCount: 120 } }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await upsertConfigHandler(req, res, mockService); + + assert.equal(mockService.upsertConfig.callCount, 1); + assert.equal(mockService.upsertConfig.lastUser, testUser); + }); + + test('returns 200 with upserted config', async () => { + const req = createMockRequest({ + params: { campusKey: 'tigers' }, + body: { data: { expectedCount: 120 } }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await upsertConfigHandler(req, res, mockService); + + assert.equal(res.statusCode, 200); + const body = res.body as { campusKey: string; expectedCount: number }; + assert.equal(body.campusKey, 'tigers'); + assert.equal(body.expectedCount, 120); + }); + }); + + describe('listSummaries', () => { + test('calls service with currentUser', async () => { + const req = createMockRequest({ + query: { campusKey: 'tigers', date: '2024-01-15' }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await listSummariesHandler(req, res, mockService); + + assert.equal(mockService.listSummaries.callCount, 1); + assert.equal(mockService.listSummaries.lastUser, testUser); + }); + + test('returns 200 with summaries list', async () => { + const req = createMockRequest({ + query: {}, + currentUser: testUser, + }); + const res = createMockResponse(); + + await listSummariesHandler(req, res, mockService); + + assert.equal(res.statusCode, 200); + const body = res.body as { rows: unknown[]; count: number }; + assert.equal(body.count, 1); + }); + }); + + describe('upsertSummary', () => { + test('calls service with currentUser', async () => { + const req = createMockRequest({ + params: { campusKey: 'tigers', date: '2024-01-15' }, + body: { data: { actualCount: 98 } }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await upsertSummaryHandler(req, res, mockService); + + assert.equal(mockService.upsertSummary.callCount, 1); + assert.equal(mockService.upsertSummary.lastUser, testUser); + }); + + test('returns 200 with upserted summary', async () => { + const req = createMockRequest({ + params: { campusKey: 'tigers', date: '2024-01-15' }, + body: { data: { actualCount: 98 } }, + currentUser: testUser, + }); + const res = createMockResponse(); + + await upsertSummaryHandler(req, res, mockService); + + assert.equal(res.statusCode, 200); + const body = res.body as { campusKey: string; date: string; actualCount: number }; + assert.equal(body.campusKey, 'tigers'); + assert.equal(body.actualCount, 98); + }); + }); +}); + +// --- Handler implementations (mirroring campus_attendance.controller.ts) --- + +async function listConfigsHandler( + req: MockRequest, + res: MockResponse, + service: MockCampusAttendanceService, +) { + const payload = await service.listConfigs.call(req.currentUser); + res.status(200).send(payload); +} + +async function upsertConfigHandler( + req: MockRequest, + res: MockResponse, + service: MockCampusAttendanceService, +) { + const payload = await service.upsertConfig.call(req.currentUser); + res.status(200).send(payload); +} + +async function listSummariesHandler( + req: MockRequest, + res: MockResponse, + service: MockCampusAttendanceService, +) { + const payload = await service.listSummaries.call(req.currentUser); + res.status(200).send(payload); +} + +async function upsertSummaryHandler( + req: MockRequest, + res: MockResponse, + service: MockCampusAttendanceService, +) { + const payload = await service.upsertSummary.call(req.currentUser); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/documents.controller.ts b/backend/src/api/controllers/documents.controller.ts deleted file mode 100644 index d8d0545..0000000 --- a/backend/src/api/controllers/documents.controller.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Request, Response } from 'express'; -import { paramStr, queryNum, queryStr } from '@/api/http/request'; -import { toCsv } from '@/shared/csv'; -import Service, { toDocumentDto } from '@/services/documents'; -import processFile from '@/middlewares/upload'; -import ValidationError from '@/shared/errors/validation'; - -const CSV_FIELDS = ['id', 'entity_reference', 'name', 'notes', 'uploaded_at']; - -function globalAccessOf(req: Request): boolean { - return req.currentUser?.app_role?.globalAccess ?? false; -} - -export async function create(req: Request, res: Response): Promise { - const document = await Service.create(req.body.data, req.currentUser); - res.status(201).send(document); -} - -export async function bulkImport(req: Request, res: Response): Promise { - await processFile(req, res); - - if (!req.file) { - throw new ValidationError('importer.errors.invalidFileEmpty'); - } - - await Service.bulkImport(req.file.buffer, req.currentUser); - res.status(200).send(true); -} - -export async function update(req: Request, res: Response): Promise { - const document = await Service.update( - req.body.data, - req.body.id, - req.currentUser, - ); - res.status(200).send(document); -} - -export async function remove(req: Request, res: Response): Promise { - await Service.remove(paramStr(req.params.id), req.currentUser); - res.status(200).send(true); -} - -export async function deleteByIds(req: Request, res: Response): Promise { - await Service.deleteByIds(req.body.data, req.currentUser); - res.status(200).send(true); -} - -export async function list(req: Request, res: Response): Promise { - const payload = await Service.list( - req.query, - globalAccessOf(req), - req.currentUser, - ); - const rows = payload.rows.map(toDocumentDto); - - if (req.query.filetype === 'csv') { - const csv = toCsv(rows, CSV_FIELDS); - res.status(200).attachment(csv); - res.send(csv); - } else { - res.status(200).send({ rows, count: payload.count }); - } -} - -export async function count(req: Request, res: Response): Promise { - const payload = await Service.count( - req.query, - globalAccessOf(req), - req.currentUser, - ); - res.status(200).send(payload); -} - -export async function autocomplete(req: Request, res: Response): Promise { - const payload = await Service.autocomplete( - queryStr(req.query.query), - queryNum(req.query.limit), - queryNum(req.query.offset), - globalAccessOf(req), - req.currentUser?.organizationId ?? undefined, - ); - res.status(200).send(payload); -} - -export async function findById(req: Request, res: Response): Promise { - const payload = await Service.findById(paramStr(req.params.id)); - res.status(200).send(payload); -} diff --git a/backend/src/api/controllers/fee_plans.controller.ts b/backend/src/api/controllers/fee_plans.controller.ts deleted file mode 100644 index a3e34a3..0000000 --- a/backend/src/api/controllers/fee_plans.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/fee_plans'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'name', 'notes', 'total_amount'] }); diff --git a/backend/src/api/controllers/file.controller.ts b/backend/src/api/controllers/file.controller.ts index 9260ee2..bdc4c1d 100644 --- a/backend/src/api/controllers/file.controller.ts +++ b/backend/src/api/controllers/file.controller.ts @@ -1,8 +1,14 @@ import type { Request, Response } from 'express'; import { paramStr } from '@/api/http/request'; import services from '@/services/file'; +import { assertCanDownloadFile } from '@/services/file-access'; + +export async function download(req: Request, res: Response): Promise { + // Enforce per-file tenant ownership before serving the path (Workstream 3 + // §3.5 / file workstream). Throws ForbiddenError on a cross-tenant fetch. + const privateUrl = String(req.query.privateUrl ?? ''); + await assertCanDownloadFile(privateUrl, req.currentUser); -export function download(req: Request, res: Response): void { if (process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_BACK_API) { void services.downloadGCloud(req, res); } else { diff --git a/backend/src/api/controllers/frame_entries.controller.ts b/backend/src/api/controllers/frame_entries.controller.ts index 070d762..b75e835 100644 --- a/backend/src/api/controllers/frame_entries.controller.ts +++ b/backend/src/api/controllers/frame_entries.controller.ts @@ -23,3 +23,8 @@ export async function update(req: Request, res: Response): Promise { ); res.status(200).send(payload); } + +export async function destroy(req: Request, res: Response): Promise { + await FrameEntriesService.destroy(paramStr(req.params.id), req.currentUser); + res.status(204).send(); +} diff --git a/backend/src/api/controllers/guardians.controller.ts b/backend/src/api/controllers/guardians.controller.ts deleted file mode 100644 index fd8685b..0000000 --- a/backend/src/api/controllers/guardians.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/guardians'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'full_name', 'phone', 'email', 'address'] }); diff --git a/backend/src/api/controllers/invoices.controller.ts b/backend/src/api/controllers/invoices.controller.ts deleted file mode 100644 index beb2563..0000000 --- a/backend/src/api/controllers/invoices.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/invoices'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'invoice_number', 'notes', 'subtotal', 'discount_amount', 'tax_amount', 'total_amount', 'balance_due', 'issue_date', 'due_date'] }); diff --git a/backend/src/api/controllers/payments.controller.ts b/backend/src/api/controllers/payments.controller.ts deleted file mode 100644 index 62bae20..0000000 --- a/backend/src/api/controllers/payments.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/payments'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'receipt_number', 'reference_code', 'notes', 'amount', 'paid_at'] }); diff --git a/backend/src/api/controllers/policy_acknowledgments.controller.ts b/backend/src/api/controllers/policy_acknowledgments.controller.ts new file mode 100644 index 0000000..3f54443 --- /dev/null +++ b/backend/src/api/controllers/policy_acknowledgments.controller.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from 'express'; +import PolicyAcknowledgmentsService from '@/services/policy_acknowledgments'; + +export async function list(req: Request, res: Response): Promise { + const payload = await PolicyAcknowledgmentsService.list( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function acknowledge(req: Request, res: Response): Promise { + const payload = await PolicyAcknowledgmentsService.acknowledge( + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/policy_documents.controller.ts b/backend/src/api/controllers/policy_documents.controller.ts new file mode 100644 index 0000000..5a38617 --- /dev/null +++ b/backend/src/api/controllers/policy_documents.controller.ts @@ -0,0 +1,6 @@ +import service from '@/services/policy_documents'; +import { createCrudController } from '@/api/controllers/shared/crud-controller'; + +export default createCrudController(service, { + csvFields: ['id', 'title', 'category', 'version', 'active'], +}); diff --git a/backend/src/api/controllers/shared/crud-controller.ts b/backend/src/api/controllers/shared/crud-controller.ts index b4416bc..552be35 100644 --- a/backend/src/api/controllers/shared/crud-controller.ts +++ b/backend/src/api/controllers/shared/crud-controller.ts @@ -39,7 +39,7 @@ export interface CrudControllerService { globalAccess: boolean, organizationId?: string, ): Promise; - findById(id: string): Promise; + findById(id: string, currentUser?: CurrentUserArg): Promise; } function globalAccessOf(req: Request): boolean { @@ -121,7 +121,10 @@ export function createCrudController( }, async findById(req: Request, res: Response): Promise { - const payload = await service.findById(paramStr(req.params.id)); + const payload = await service.findById( + paramStr(req.params.id), + req.currentUser, + ); res.status(200).send(payload); }, }; diff --git a/backend/src/api/controllers/students.controller.ts b/backend/src/api/controllers/students.controller.ts deleted file mode 100644 index 7eefbff..0000000 --- a/backend/src/api/controllers/students.controller.ts +++ /dev/null @@ -1,4 +0,0 @@ -import service from '@/services/students'; -import { createCrudController } from '@/api/controllers/shared/crud-controller'; - -export default createCrudController(service, { csvFields: ['id', 'student_number', 'first_name', 'last_name', 'email', 'phone', 'address', 'date_of_birth', 'enrollment_date'] }); diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index ef35722..7b43af1 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -1,7 +1,6 @@ import passport from 'passport'; import { Strategy as JwtStrategy } from 'passport-jwt'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth2'; -import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import type { Request } from 'express'; import config from '@/shared/config'; import db from '@/db/models'; @@ -16,6 +15,10 @@ interface JwtPayload { type VerifyDone = (error: unknown, user?: unknown) => void; +// The social strategies' verify callback (compatible with passport-oauth2's +// `VerifyCallback`, which types `user` as `Express.User | false`). +type SocialDone = (error: unknown, user?: Express.User | false) => void; + passport.use( new JwtStrategy( { @@ -45,7 +48,7 @@ function socialStrategy( email: string, _profile: unknown, provider: string, - done: VerifyDone, + done: SocialDone, ): void { db.users .findOrCreate({ where: { email, provider } }) @@ -63,24 +66,10 @@ if (config.google.clientId && config.google.clientSecret) { passReqToCallback: true, }, (_request, _accessToken, _refreshToken, profile, done) => { - socialStrategy(profile.email ?? '', profile, providers.GOOGLE, done); - }, - ), - ); -} - -if (config.microsoft.clientId && config.microsoft.clientSecret) { - passport.use( - new MicrosoftStrategy( - { - clientID: config.microsoft.clientId, - clientSecret: config.microsoft.clientSecret, - callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', - passReqToCallback: true, - }, - (_request, _accessToken, _refreshToken, profile, done) => { - const email = profile._json.mail || profile._json.userPrincipalName || ''; - socialStrategy(email, profile, providers.MICROSOFT, done); + // passport-google-oauth20 exposes emails as a typed array (the scope + // `email` is requested in the signin controller). + const email = profile.emails?.[0]?.value ?? ''; + socialStrategy(email, profile, providers.GOOGLE, done); }, ), ); diff --git a/backend/src/db/api/academic_years.ts b/backend/src/db/api/academic_years.ts index 9a53685..339f483 100644 --- a/backend/src/db/api/academic_years.ts +++ b/backend/src/db/api/academic_years.ts @@ -9,6 +9,8 @@ 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'; @@ -100,7 +102,7 @@ class Academic_yearsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const academic_years = await db.academic_years.findByPk(id, { transaction }); + const academic_years = await findOwnedByPk(db.academic_years, id, options); if (!academic_years) { return null; @@ -149,7 +151,7 @@ class Academic_yearsDBApi { const transaction = options?.transaction; const academic_years = await db.academic_years.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); @@ -162,17 +164,14 @@ class Academic_yearsDBApi { const [ classes_academic_year, timetables_academic_year, - fee_plans_academic_year, organization, ] = await Promise.all([ academic_years.getClasses_academic_year({ transaction }), academic_years.getTimetables_academic_year({ transaction }), - academic_years.getFee_plans_academic_year({ transaction }), academic_years.getOrganization({ transaction }), ]); output.classes_academic_year = classes_academic_year; output.timetables_academic_year = timetables_academic_year; - output.fee_plans_academic_year = fee_plans_academic_year; output.organization = organization; return output; diff --git a/backend/src/db/api/assessment_results.ts b/backend/src/db/api/assessment_results.ts index 8ba6f35..0282027 100644 --- a/backend/src/db/api/assessment_results.ts +++ b/backend/src/db/api/assessment_results.ts @@ -10,6 +10,8 @@ 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'; @@ -71,9 +73,6 @@ class Assessment_resultsDBApi { await assessment_results.setAssessment(data.assessment ?? undefined, { transaction, }); - await assessment_results.setStudent(data.student ?? undefined, { - transaction, - }); return assessment_results; } @@ -110,9 +109,7 @@ class Assessment_resultsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const assessment_results = await db.assessment_results.findByPk(id, { - transaction, - }); + const assessment_results = await findOwnedByPk(db.assessment_results, id, options); if (!assessment_results) { return null; @@ -142,11 +139,6 @@ class Assessment_resultsDBApi { transaction, }); } - if (data.student !== undefined) { - await assessment_results.setStudent(data.student ?? undefined, { - transaction, - }); - } return assessment_results; } @@ -172,7 +164,7 @@ class Assessment_resultsDBApi { const transaction = options?.transaction; const assessment_results = await db.assessment_results.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); @@ -184,14 +176,12 @@ class Assessment_resultsDBApi { plain: true, }); - const [organization, assessment, student] = await Promise.all([ + const [organization, assessment] = await Promise.all([ assessment_results.getOrganization({ transaction }), assessment_results.getAssessment({ transaction }), - assessment_results.getStudent({ transaction }), ]); output.organization = organization; output.assessment = assessment; - output.student = student; return output; } @@ -236,28 +226,6 @@ class Assessment_resultsDBApi { } : {}, }, - { - model: db.students, - as: 'student', - where: filter.student - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - student_number: { - [Op.or]: filter.student - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, ]; if (filter.id) { diff --git a/backend/src/db/api/assessments.ts b/backend/src/db/api/assessments.ts index 8f9b399..081c00c 100644 --- a/backend/src/db/api/assessments.ts +++ b/backend/src/db/api/assessments.ts @@ -10,6 +10,8 @@ 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'; @@ -148,7 +150,7 @@ class AssessmentsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const assessments = await db.assessments.findByPk(id, { transaction }); + const assessments = await findOwnedByPk(db.assessments, id, options); if (!assessments) { return null; @@ -216,7 +218,10 @@ class AssessmentsDBApi { ): Promise | null> { const transaction = options?.transaction; - const assessments = await db.assessments.findOne({ where, transaction }); + const assessments = await db.assessments.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!assessments) { return null; diff --git a/backend/src/db/api/attendance_records.ts b/backend/src/db/api/attendance_records.ts index 7e270fd..f46aa42 100644 --- a/backend/src/db/api/attendance_records.ts +++ b/backend/src/db/api/attendance_records.ts @@ -10,6 +10,8 @@ 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'; @@ -72,9 +74,6 @@ class Attendance_recordsDBApi { data.attendance_session ?? undefined, { transaction }, ); - await attendance_records.setStudent(data.student ?? undefined, { - transaction, - }); return attendance_records; } @@ -111,9 +110,7 @@ class Attendance_recordsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const attendance_records = await db.attendance_records.findByPk(id, { - transaction, - }); + const attendance_records = await findOwnedByPk(db.attendance_records, id, options); if (!attendance_records) { return null; @@ -144,11 +141,6 @@ class Attendance_recordsDBApi { { transaction }, ); } - if (data.student !== undefined) { - await attendance_records.setStudent(data.student ?? undefined, { - transaction, - }); - } return attendance_records; } @@ -174,7 +166,7 @@ class Attendance_recordsDBApi { const transaction = options?.transaction; const attendance_records = await db.attendance_records.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); @@ -186,14 +178,12 @@ class Attendance_recordsDBApi { plain: true, }); - const [organization, attendance_session, student] = await Promise.all([ + const [organization, attendance_session] = await Promise.all([ attendance_records.getOrganization({ transaction }), attendance_records.getAttendance_session({ transaction }), - attendance_records.getStudent({ transaction }), ]); output.organization = organization; output.attendance_session = attendance_session; - output.student = student; return output; } @@ -238,28 +228,6 @@ class Attendance_recordsDBApi { } : {}, }, - { - model: db.students, - as: 'student', - where: filter.student - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - student_number: { - [Op.or]: filter.student - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, ]; if (filter.id) { diff --git a/backend/src/db/api/attendance_sessions.ts b/backend/src/db/api/attendance_sessions.ts index b7570ff..3444c61 100644 --- a/backend/src/db/api/attendance_sessions.ts +++ b/backend/src/db/api/attendance_sessions.ts @@ -10,6 +10,8 @@ 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'; @@ -118,9 +120,7 @@ class Attendance_sessionsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const attendance_sessions = await db.attendance_sessions.findByPk(id, { - transaction, - }); + const attendance_sessions = await findOwnedByPk(db.attendance_sessions, id, options); if (!attendance_sessions) { return null; @@ -192,7 +192,7 @@ class Attendance_sessionsDBApi { const transaction = options?.transaction; const attendance_sessions = await db.attendance_sessions.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); diff --git a/backend/src/db/api/auth_refresh_tokens.ts b/backend/src/db/api/auth_refresh_tokens.ts index ee688b0..b9bceff 100644 --- a/backend/src/db/api/auth_refresh_tokens.ts +++ b/backend/src/db/api/auth_refresh_tokens.ts @@ -1,3 +1,4 @@ +import { Op } from 'sequelize'; import db from '@/db/models'; import type { AuthRefreshTokens } from '@/db/models/auth_refresh_tokens'; import type { DbApiOptions } from '@/db/api/types'; @@ -79,6 +80,22 @@ class AuthRefreshTokensDBApi { }, ); } + + /** + * Physically deletes refresh-token rows that expired before `cutoff`. Used by + * the maintenance job; a row past `expiresAt` can no longer be presented, so + * removing it (revoked or not) is safe once the retention window has passed. + * Returns the number of rows deleted. + */ + static async deleteExpiredBefore( + cutoff: Date, + options: DbApiOptions = {}, + ): Promise { + return db.auth_refresh_tokens.destroy({ + where: { expiresAt: { [Op.lt]: cutoff } }, + transaction: options.transaction, + }); + } } export default AuthRefreshTokensDBApi; diff --git a/backend/src/db/api/campuses.ts b/backend/src/db/api/campuses.ts index 1b28c82..98ee091 100644 --- a/backend/src/db/api/campuses.ts +++ b/backend/src/db/api/campuses.ts @@ -9,6 +9,8 @@ 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'; @@ -128,7 +130,7 @@ class CampusesDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const campuses = await db.campuses.findByPk(id, { transaction }); + const campuses = await findOwnedByPk(db.campuses, id, options); if (!campuses) { return null; @@ -187,7 +189,10 @@ class CampusesDBApi { ): Promise | null> { const transaction = options?.transaction; - const campuses = await db.campuses.findOne({ where, transaction }); + const campuses = await db.campuses.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!campuses) { return null; @@ -196,34 +201,25 @@ class CampusesDBApi { const output: Record = campuses.get({ plain: true }); const [ - students_campus, staff_campus, classes_campus, timetables_campus, attendance_sessions_campus, - invoices_campus, messages_campus, - documents_campus, organization, ] = await Promise.all([ - campuses.getStudents_campus({ transaction }), campuses.getStaff_campus({ transaction }), campuses.getClasses_campus({ transaction }), campuses.getTimetables_campus({ transaction }), campuses.getAttendance_sessions_campus({ transaction }), - campuses.getInvoices_campus({ transaction }), campuses.getMessages_campus({ transaction }), - campuses.getDocuments_campus({ transaction }), campuses.getOrganization({ transaction }), ]); - output.students_campus = students_campus; output.staff_campus = staff_campus; output.classes_campus = classes_campus; output.timetables_campus = timetables_campus; output.attendance_sessions_campus = attendance_sessions_campus; - output.invoices_campus = invoices_campus; output.messages_campus = messages_campus; - output.documents_campus = documents_campus; output.organization = organization; return output; diff --git a/backend/src/db/api/class_enrollments.ts b/backend/src/db/api/class_enrollments.ts index eb071b7..bcadbce 100644 --- a/backend/src/db/api/class_enrollments.ts +++ b/backend/src/db/api/class_enrollments.ts @@ -10,6 +10,8 @@ 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'; @@ -69,9 +71,6 @@ class Class_enrollmentsDBApi { { transaction }, ); await class_enrollments.setClass(data.class ?? undefined, { transaction }); - await class_enrollments.setStudent(data.student ?? undefined, { - transaction, - }); return class_enrollments; } @@ -108,9 +107,7 @@ class Class_enrollmentsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const class_enrollments = await db.class_enrollments.findByPk(id, { - transaction, - }); + const class_enrollments = await findOwnedByPk(db.class_enrollments, id, options); if (!class_enrollments) { return null; @@ -140,11 +137,6 @@ class Class_enrollmentsDBApi { transaction, }); } - if (data.student !== undefined) { - await class_enrollments.setStudent(data.student ?? undefined, { - transaction, - }); - } return class_enrollments; } @@ -170,7 +162,7 @@ class Class_enrollmentsDBApi { const transaction = options?.transaction; const class_enrollments = await db.class_enrollments.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); @@ -182,14 +174,12 @@ class Class_enrollmentsDBApi { plain: true, }); - const [organization, class_, student] = await Promise.all([ + const [organization, class_] = await Promise.all([ class_enrollments.getOrganization({ transaction }), class_enrollments.getClass({ transaction }), - class_enrollments.getStudent({ transaction }), ]); output.organization = organization; output.class = class_; - output.student = student; return output; } @@ -232,28 +222,6 @@ class Class_enrollmentsDBApi { } : {}, }, - { - model: db.students, - as: 'student', - where: filter.student - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - student_number: { - [Op.or]: filter.student - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, ]; if (filter.id) { diff --git a/backend/src/db/api/class_subjects.ts b/backend/src/db/api/class_subjects.ts index eef796f..d6c5879 100644 --- a/backend/src/db/api/class_subjects.ts +++ b/backend/src/db/api/class_subjects.ts @@ -10,6 +10,8 @@ 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'; @@ -99,7 +101,7 @@ class Class_subjectsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const class_subjects = await db.class_subjects.findByPk(id, { transaction }); + const class_subjects = await findOwnedByPk(db.class_subjects, id, options); if (!class_subjects) { return null; @@ -157,7 +159,7 @@ class Class_subjectsDBApi { const transaction = options?.transaction; const class_subjects = await db.class_subjects.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); diff --git a/backend/src/db/api/classes.ts b/backend/src/db/api/classes.ts index febaf85..ddef857 100644 --- a/backend/src/db/api/classes.ts +++ b/backend/src/db/api/classes.ts @@ -10,6 +10,8 @@ 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'; @@ -114,7 +116,7 @@ class ClassesDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const classes = await db.classes.findByPk(id, { transaction }); + const classes = await findOwnedByPk(db.classes, id, options); if (!classes) { return null; @@ -177,7 +179,10 @@ class ClassesDBApi { ): Promise | null> { const transaction = options?.transaction; - const classes = await db.classes.findOne({ where, transaction }); + const classes = await db.classes.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!classes) { return null; diff --git a/backend/src/db/api/documents.ts b/backend/src/db/api/documents.ts deleted file mode 100644 index 3e7cc84..0000000 --- a/backend/src/db/api/documents.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 { Documents } from '@/db/models/documents'; -import type { - CurrentUser, - DbApiOptions, - FileInput, -} from '@/db/api/types'; - -type DocumentsData = Partial> & { - organization?: string | null; - campus?: string | null; - file?: FileInput | FileInput[] | null; -}; - -interface DocumentsFilter { - limit?: number | string; - page?: number | string; - id?: string; - entity_reference?: string; - name?: string; - notes?: string; - uploaded_atRange?: Array; - active?: boolean | string; - entity_type?: string; - category?: string; - campus?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -function documentsTableName(): string { - const name = db.documents.getTableName(); - return typeof name === 'string' ? name : name.tableName; -} - -class DocumentsDBApi { - static async create( - data: DocumentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const documents = await db.documents.create( - { - id: data.id || undefined, - entity_type: data.entity_type || null, - entity_reference: data.entity_reference || null, - name: data.name || null, - category: data.category || null, - uploaded_at: data.uploaded_at || null, - notes: data.notes || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await documents.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await documents.setCampus(data.campus ?? undefined, { transaction }); - - await FileDBApi.replaceRelationFiles( - { - belongsTo: documentsTableName(), - belongsToColumn: 'file', - belongsToId: documents.id, - }, - data.file, - options, - ); - - return documents; - } - - static async bulkImport( - data: DocumentsData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const documentsData = data.map((item, index) => ({ - id: item.id || undefined, - entity_type: item.entity_type || null, - entity_reference: item.entity_reference || null, - name: item.name || null, - category: item.category || null, - uploaded_at: item.uploaded_at || null, - notes: item.notes || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - const documents = await db.documents.bulkCreate(documentsData, { - transaction, - }); - - for (let i = 0; i < documents.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: documentsTableName(), - belongsToColumn: 'file', - belongsToId: documents[i].id, - }, - data[i].file, - options, - ); - } - - return documents; - } - - static async update( - id: string, - data: DocumentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const documents = await db.documents.findByPk(id, { transaction }); - - if (!documents) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.entity_type !== undefined) - updatePayload.entity_type = data.entity_type; - if (data.entity_reference !== undefined) - updatePayload.entity_reference = data.entity_reference; - if (data.name !== undefined) updatePayload.name = data.name; - if (data.category !== undefined) updatePayload.category = data.category; - if (data.uploaded_at !== undefined) - updatePayload.uploaded_at = data.uploaded_at; - if (data.notes !== undefined) updatePayload.notes = data.notes; - - updatePayload.updatedById = currentUser.id; - - await documents.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await documents.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.campus !== undefined) { - await documents.setCampus(data.campus ?? undefined, { transaction }); - } - - await FileDBApi.replaceRelationFiles( - { - belongsTo: documentsTableName(), - belongsToColumn: 'file', - belongsToId: documents.id, - }, - data.file, - options, - ); - - return documents; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.documents, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.documents, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const documents = await db.documents.findOne({ where, transaction }); - - if (!documents) { - return null; - } - - const output: Record = documents.get({ plain: true }); - - const [organization, campus, file] = await Promise.all([ - documents.getOrganization({ transaction }), - documents.getCampus({ transaction }), - documents.getFile({ transaction }), - ]); - output.organization = organization; - output.campus = campus; - output.file = file; - - return output; - } - - static async findAll( - filter: DocumentsFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Documents[]; 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; - } - - // The list DTO (`toDocumentDto`) returns only scalar columns, so we don't - // eager-load organization/file. The campus join is added only when filtering - // by campus, and selects no columns (filter-only, inner join). - const include: Includeable[] = []; - if (filter.campus) { - include.push({ - model: db.campuses, - as: 'campus', - attributes: [], - required: true, - where: { - [Op.or]: [ - { - id: { - [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - name: { - [Op.or]: filter.campus - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - }, - }); - } - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.entity_reference) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'documents', - 'entity_reference', - filter.entity_reference, - ), - }; - } - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike('documents', 'name', filter.name), - }; - } - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike('documents', 'notes', filter.notes), - }; - } - if (filter.uploaded_atRange) { - const [start, end] = filter.uploaded_atRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, uploaded_at: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - uploaded_at: { - ...(typeof where.uploaded_at === 'object' ? where.uploaded_at : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.entity_type) { - where = { ...where, entity_type: filter.entity_type }; - } - if (filter.category) { - where = { ...where, category: filter.category }; - } - 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.documents.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.documents, - 'name', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default DocumentsDBApi; diff --git a/backend/src/db/api/fee_plans.ts b/backend/src/db/api/fee_plans.ts deleted file mode 100644 index 36aecc8..0000000 --- a/backend/src/db/api/fee_plans.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 type { FeePlans } from '@/db/models/fee_plans'; -import type { CurrentUser, DbApiOptions } from '@/db/api/types'; - -type FeePlansData = Partial> & { - organization?: string | null; - academic_year?: string | null; - grade?: string | null; -}; - -interface FeePlansFilter { - limit?: number | string; - page?: number | string; - id?: string; - name?: string; - notes?: string; - total_amountRange?: Array; - active?: boolean | string; - billing_cycle?: string; - academic_year?: string; - grade?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -class Fee_plansDBApi { - static async create( - data: FeePlansData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const fee_plans = await db.fee_plans.create( - { - id: data.id || undefined, - name: data.name || null, - billing_cycle: data.billing_cycle || null, - total_amount: data.total_amount || null, - active: data.active || false, - notes: data.notes || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await fee_plans.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await fee_plans.setAcademic_year(data.academic_year ?? undefined, { - transaction, - }); - await fee_plans.setGrade(data.grade ?? undefined, { transaction }); - - return fee_plans; - } - - static async bulkImport( - data: FeePlansData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const fee_plansData = data.map((item, index) => ({ - id: item.id || undefined, - name: item.name || null, - billing_cycle: item.billing_cycle || null, - total_amount: item.total_amount || null, - active: item.active || false, - notes: item.notes || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - return db.fee_plans.bulkCreate(fee_plansData, { transaction }); - } - - static async update( - id: string, - data: FeePlansData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const fee_plans = await db.fee_plans.findByPk(id, { transaction }); - - if (!fee_plans) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - if (data.billing_cycle !== undefined) - updatePayload.billing_cycle = data.billing_cycle; - if (data.total_amount !== undefined) - updatePayload.total_amount = data.total_amount; - if (data.active !== undefined) updatePayload.active = data.active; - if (data.notes !== undefined) updatePayload.notes = data.notes; - - updatePayload.updatedById = currentUser.id; - - await fee_plans.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await fee_plans.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.academic_year !== undefined) { - await fee_plans.setAcademic_year(data.academic_year ?? undefined, { - transaction, - }); - } - if (data.grade !== undefined) { - await fee_plans.setGrade(data.grade ?? undefined, { transaction }); - } - - return fee_plans; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.fee_plans, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.fee_plans, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const fee_plans = await db.fee_plans.findOne({ where, transaction }); - - if (!fee_plans) { - return null; - } - - const output: Record = fee_plans.get({ plain: true }); - - const [invoices_fee_plan, organization, academic_year, grade] = - await Promise.all([ - fee_plans.getInvoices_fee_plan({ transaction }), - fee_plans.getOrganization({ transaction }), - fee_plans.getAcademic_year({ transaction }), - fee_plans.getGrade({ transaction }), - ]); - output.invoices_fee_plan = invoices_fee_plan; - output.organization = organization; - output.academic_year = academic_year; - output.grade = grade; - - return output; - } - - static async findAll( - filter: FeePlansFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: FeePlans[]; 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.academic_years, - as: 'academic_year', - where: filter.academic_year - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.academic_year - .split('|') - .map((t) => Utils.uuid(t)), - }, - }, - { - name: { - [Op.or]: filter.academic_year - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { - model: db.grades, - as: 'grade', - where: filter.grade - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.grade.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - name: { - [Op.or]: filter.grade - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike('fee_plans', 'name', filter.name), - }; - } - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike('fee_plans', 'notes', filter.notes), - }; - } - if (filter.total_amountRange) { - const [start, end] = filter.total_amountRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, total_amount: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - total_amount: { - ...(typeof where.total_amount === 'object' - ? where.total_amount - : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.billing_cycle) { - where = { ...where, billing_cycle: filter.billing_cycle }; - } - if (filter.active) { - where = { ...where, active: filter.active }; - } - 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.fee_plans.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.fee_plans, - 'name', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default Fee_plansDBApi; diff --git a/backend/src/db/api/file.ts b/backend/src/db/api/file.ts index ec3d9d2..a214e28 100644 --- a/backend/src/db/api/file.ts +++ b/backend/src/db/api/file.ts @@ -86,6 +86,32 @@ class FileDBApi { await file.destroy({ transaction }); } } + + /** + * Resolves the owning organization of a stored file by its `privateUrl`, via + * the uploader (`createdById`). Used by the download ownership check + * (Workstream 3 §3.5 / file workstream) so a file cannot be fetched + * cross-tenant by guessing its path. `found: false` means no tracked file + * matches the path (callers deny by default). + */ + static async findOwnerOrganizationIdByPrivateUrl( + privateUrl: string, + ): Promise<{ found: boolean; organizationId: string | null }> { + const row = await db.file.findOne({ + where: { privateUrl }, + attributes: ['id', 'createdById'], + }); + if (!row) { + return { found: false, organizationId: null }; + } + if (!row.createdById) { + return { found: true, organizationId: null }; + } + const creator = await db.users.findByPk(row.createdById, { + attributes: ['organizationId'], + }); + return { found: true, organizationId: creator?.organizationId ?? null }; + } } export default FileDBApi; diff --git a/backend/src/db/api/grades.ts b/backend/src/db/api/grades.ts index 4c5b6a8..452acbd 100644 --- a/backend/src/db/api/grades.ts +++ b/backend/src/db/api/grades.ts @@ -9,6 +9,8 @@ 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'; @@ -97,7 +99,7 @@ class GradesDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const grades = await db.grades.findByPk(id, { transaction }); + const grades = await findOwnedByPk(db.grades, id, options); if (!grades) { return null; @@ -146,7 +148,10 @@ class GradesDBApi { ): Promise | null> { const transaction = options?.transaction; - const grades = await db.grades.findOne({ where, transaction }); + const grades = await db.grades.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!grades) { return null; @@ -154,13 +159,11 @@ class GradesDBApi { const output: Record = grades.get({ plain: true }); - const [classes_grade, fee_plans_grade, organization] = await Promise.all([ + const [classes_grade, organization] = await Promise.all([ grades.getClasses_grade({ transaction }), - grades.getFee_plans_grade({ transaction }), grades.getOrganization({ transaction }), ]); output.classes_grade = classes_grade; - output.fee_plans_grade = fee_plans_grade; output.organization = organization; return output; diff --git a/backend/src/db/api/guardians.ts b/backend/src/db/api/guardians.ts deleted file mode 100644 index 28a50e7..0000000 --- a/backend/src/db/api/guardians.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 type { Guardians } from '@/db/models/guardians'; -import type { CurrentUser, DbApiOptions } from '@/db/api/types'; - -type GuardiansData = Partial> & { - organization?: string | null; - student?: string | null; -}; - -interface GuardiansFilter { - limit?: number | string; - page?: number | string; - id?: string; - full_name?: string; - phone?: string; - email?: string; - address?: string; - active?: boolean | string; - relationship?: string; - primary_contact?: boolean | string; - student?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -class GuardiansDBApi { - static async create( - data: GuardiansData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const guardians = await db.guardians.create( - { - id: data.id || undefined, - full_name: data.full_name || null, - relationship: data.relationship || null, - phone: data.phone || null, - email: data.email || null, - address: data.address || null, - primary_contact: data.primary_contact || false, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await guardians.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await guardians.setStudent(data.student ?? undefined, { transaction }); - - return guardians; - } - - static async bulkImport( - data: GuardiansData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const guardiansData = data.map((item, index) => ({ - id: item.id || undefined, - full_name: item.full_name || null, - relationship: item.relationship || null, - phone: item.phone || null, - email: item.email || null, - address: item.address || null, - primary_contact: item.primary_contact || false, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - return db.guardians.bulkCreate(guardiansData, { transaction }); - } - - static async update( - id: string, - data: GuardiansData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const guardians = await db.guardians.findByPk(id, { transaction }); - - if (!guardians) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.full_name !== undefined) updatePayload.full_name = data.full_name; - if (data.relationship !== undefined) - updatePayload.relationship = data.relationship; - if (data.phone !== undefined) updatePayload.phone = data.phone; - if (data.email !== undefined) updatePayload.email = data.email; - if (data.address !== undefined) updatePayload.address = data.address; - if (data.primary_contact !== undefined) - updatePayload.primary_contact = data.primary_contact; - - updatePayload.updatedById = currentUser.id; - - await guardians.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await guardians.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.student !== undefined) { - await guardians.setStudent(data.student ?? undefined, { transaction }); - } - - return guardians; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.guardians, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.guardians, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const guardians = await db.guardians.findOne({ where, transaction }); - - if (!guardians) { - return null; - } - - const output: Record = guardians.get({ plain: true }); - - const [organization, student] = await Promise.all([ - guardians.getOrganization({ transaction }), - guardians.getStudent({ transaction }), - ]); - output.organization = organization; - output.student = student; - - return output; - } - - static async findAll( - filter: GuardiansFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Guardians[]; 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.students, - as: 'student', - where: filter.student - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - student_number: { - [Op.or]: filter.student - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.full_name) { - where = { - ...where, - [Op.and]: Utils.ilike('guardians', 'full_name', filter.full_name), - }; - } - if (filter.phone) { - where = { - ...where, - [Op.and]: Utils.ilike('guardians', 'phone', filter.phone), - }; - } - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike('guardians', 'email', filter.email), - }; - } - if (filter.address) { - where = { - ...where, - [Op.and]: Utils.ilike('guardians', 'address', filter.address), - }; - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.relationship) { - where = { ...where, relationship: filter.relationship }; - } - if (filter.primary_contact) { - where = { ...where, primary_contact: filter.primary_contact }; - } - 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.guardians.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.guardians, - 'full_name', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default GuardiansDBApi; diff --git a/backend/src/db/api/invoices.ts b/backend/src/db/api/invoices.ts deleted file mode 100644 index 3100e8d..0000000 --- a/backend/src/db/api/invoices.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 { Invoices } from '@/db/models/invoices'; -import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; - -type InvoicesData = Partial> & { - organization?: string | null; - campus?: string | null; - student?: string | null; - fee_plan?: string | null; - attachments?: FileInput | FileInput[] | null; -}; - -type NumberRange = Array; -type DateRange = Array; - -interface InvoicesFilter { - limit?: number | string; - page?: number | string; - id?: string; - invoice_number?: string; - notes?: string; - issue_dateRange?: DateRange; - due_dateRange?: DateRange; - subtotalRange?: NumberRange; - discount_amountRange?: NumberRange; - tax_amountRange?: NumberRange; - total_amountRange?: NumberRange; - balance_dueRange?: NumberRange; - active?: boolean | string; - status?: string; - campus?: string; - student?: string; - fee_plan?: string; - organization?: string; - createdAtRange?: DateRange; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -function invoicesTableName(): string { - const name = db.invoices.getTableName(); - return typeof name === 'string' ? name : name.tableName; -} - -/** Apply a `>= / <=` range to a where field, preserving an existing bound. */ -function applyRange( - where: WhereAttributeHash, - field: string, - range: NumberRange | DateRange | undefined, -): WhereAttributeHash { - if (!range) return where; - const [start, end] = range; - let next = where; - if (start !== undefined && start !== null && start !== '') { - next = { ...next, [field]: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - const existing = next[field]; - next = { - ...next, - [field]: { - ...(typeof existing === 'object' && existing !== null ? existing : {}), - [Op.lte]: end, - }, - }; - } - return next; -} - -class InvoicesDBApi { - static async create( - data: InvoicesData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const invoices = await db.invoices.create( - { - id: data.id || undefined, - invoice_number: data.invoice_number || null, - issue_date: data.issue_date || null, - due_date: data.due_date || null, - subtotal: data.subtotal || null, - discount_amount: data.discount_amount || null, - tax_amount: data.tax_amount || null, - total_amount: data.total_amount || null, - balance_due: data.balance_due || null, - status: data.status || null, - notes: data.notes || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await invoices.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await invoices.setCampus(data.campus ?? undefined, { transaction }); - await invoices.setStudent(data.student ?? undefined, { transaction }); - await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction }); - - await FileDBApi.replaceRelationFiles( - { - belongsTo: invoicesTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices.id, - }, - data.attachments, - options, - ); - - return invoices; - } - - static async bulkImport( - data: InvoicesData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const invoicesData = data.map((item, index) => ({ - id: item.id || undefined, - invoice_number: item.invoice_number || null, - issue_date: item.issue_date || null, - due_date: item.due_date || null, - subtotal: item.subtotal || null, - discount_amount: item.discount_amount || null, - tax_amount: item.tax_amount || null, - total_amount: item.total_amount || null, - balance_due: item.balance_due || null, - status: item.status || null, - notes: item.notes || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - const invoices = await db.invoices.bulkCreate(invoicesData, { transaction }); - - for (let i = 0; i < invoices.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: invoicesTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices[i].id, - }, - data[i].attachments, - options, - ); - } - - return invoices; - } - - static async update( - id: string, - data: InvoicesData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const invoices = await db.invoices.findByPk(id, { transaction }); - - if (!invoices) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.invoice_number !== undefined) - updatePayload.invoice_number = data.invoice_number; - if (data.issue_date !== undefined) - updatePayload.issue_date = data.issue_date; - if (data.due_date !== undefined) updatePayload.due_date = data.due_date; - if (data.subtotal !== undefined) updatePayload.subtotal = data.subtotal; - if (data.discount_amount !== undefined) - updatePayload.discount_amount = data.discount_amount; - if (data.tax_amount !== undefined) - updatePayload.tax_amount = data.tax_amount; - if (data.total_amount !== undefined) - updatePayload.total_amount = data.total_amount; - if (data.balance_due !== undefined) - updatePayload.balance_due = data.balance_due; - if (data.status !== undefined) updatePayload.status = data.status; - if (data.notes !== undefined) updatePayload.notes = data.notes; - - updatePayload.updatedById = currentUser.id; - - await invoices.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await invoices.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.campus !== undefined) { - await invoices.setCampus(data.campus ?? undefined, { transaction }); - } - if (data.student !== undefined) { - await invoices.setStudent(data.student ?? undefined, { transaction }); - } - if (data.fee_plan !== undefined) { - await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction }); - } - - await FileDBApi.replaceRelationFiles( - { - belongsTo: invoicesTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices.id, - }, - data.attachments, - options, - ); - - return invoices; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.invoices, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.invoices, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const invoices = await db.invoices.findOne({ where, transaction }); - - if (!invoices) { - return null; - } - - const output: Record = invoices.get({ plain: true }); - - const [ - payments_invoice, - organization, - campus, - student, - fee_plan, - attachments, - ] = await Promise.all([ - invoices.getPayments_invoice({ transaction }), - invoices.getOrganization({ transaction }), - invoices.getCampus({ transaction }), - invoices.getStudent({ transaction }), - invoices.getFee_plan({ transaction }), - invoices.getAttachments({ transaction }), - ]); - output.payments_invoice = payments_invoice; - output.organization = organization; - output.campus = campus; - output.student = student; - output.fee_plan = fee_plan; - output.attachments = attachments; - - return output; - } - - static async findAll( - filter: InvoicesFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Invoices[]; 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.students, - as: 'student', - where: filter.student - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - student_number: { - [Op.or]: filter.student - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { - model: db.fee_plans, - as: 'fee_plan', - where: filter.fee_plan - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.fee_plan - .split('|') - .map((t) => Utils.uuid(t)), - }, - }, - { - name: { - [Op.or]: filter.fee_plan - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { model: db.file, as: 'attachments' }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.invoice_number) { - where = { - ...where, - [Op.and]: Utils.ilike('invoices', 'invoice_number', filter.invoice_number), - }; - } - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike('invoices', 'notes', filter.notes), - }; - } - - where = applyRange(where, 'issue_date', filter.issue_dateRange); - where = applyRange(where, 'due_date', filter.due_dateRange); - where = applyRange(where, 'subtotal', filter.subtotalRange); - where = applyRange(where, 'discount_amount', filter.discount_amountRange); - where = applyRange(where, 'tax_amount', filter.tax_amountRange); - where = applyRange(where, 'total_amount', filter.total_amountRange); - where = applyRange(where, 'balance_due', filter.balance_dueRange); - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - 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 } }; - } - where = applyRange(where, 'createdAt', filter.createdAtRange); - - if (globalAccess) { - delete where.organizationId; - } - - const order: [string, string][] = - filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']]; - - const { rows, count } = await db.invoices.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.invoices, - 'invoice_number', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default InvoicesDBApi; diff --git a/backend/src/db/api/message_recipients.ts b/backend/src/db/api/message_recipients.ts index caa1e23..be5f75d 100644 --- a/backend/src/db/api/message_recipients.ts +++ b/backend/src/db/api/message_recipients.ts @@ -10,6 +10,8 @@ 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'; @@ -114,9 +116,7 @@ class Message_recipientsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const message_recipients = await db.message_recipients.findByPk(id, { - transaction, - }); + const message_recipients = await findOwnedByPk(db.message_recipients, id, options); if (!message_recipients) { return null; @@ -178,7 +178,7 @@ class Message_recipientsDBApi { const transaction = options?.transaction; const message_recipients = await db.message_recipients.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); diff --git a/backend/src/db/api/messages.ts b/backend/src/db/api/messages.ts index 77b90b9..0c21985 100644 --- a/backend/src/db/api/messages.ts +++ b/backend/src/db/api/messages.ts @@ -10,6 +10,8 @@ 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'; @@ -141,7 +143,7 @@ class MessagesDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const messages = await db.messages.findByPk(id, { transaction }); + const messages = await findOwnedByPk(db.messages, id, options); if (!messages) { return null; @@ -206,7 +208,10 @@ class MessagesDBApi { ): Promise | null> { const transaction = options?.transaction; - const messages = await db.messages.findOne({ where, transaction }); + const messages = await db.messages.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!messages) { return null; diff --git a/backend/src/db/api/organizations.ts b/backend/src/db/api/organizations.ts index d5a99f5..ce8d3b4 100644 --- a/backend/src/db/api/organizations.ts +++ b/backend/src/db/api/organizations.ts @@ -129,8 +129,6 @@ class OrganizationsDBApi { academic_years_organization, grades_organization, subjects_organization, - students_organization, - guardians_organization, staff_organization, classes_organization, class_enrollments_organization, @@ -139,22 +137,16 @@ class OrganizationsDBApi { timetable_periods_organization, attendance_sessions_organization, attendance_records_organization, - fee_plans_organization, - invoices_organization, - payments_organization, assessments_organization, assessment_results_organization, messages_organization, message_recipients_organization, - documents_organization, ] = await Promise.all([ organizations.getUsers_organizations({ transaction }), organizations.getCampuses_organization({ transaction }), organizations.getAcademic_years_organization({ transaction }), organizations.getGrades_organization({ transaction }), organizations.getSubjects_organization({ transaction }), - organizations.getStudents_organization({ transaction }), - organizations.getGuardians_organization({ transaction }), organizations.getStaff_organization({ transaction }), organizations.getClasses_organization({ transaction }), organizations.getClass_enrollments_organization({ transaction }), @@ -163,22 +155,16 @@ class OrganizationsDBApi { organizations.getTimetable_periods_organization({ transaction }), organizations.getAttendance_sessions_organization({ transaction }), organizations.getAttendance_records_organization({ transaction }), - organizations.getFee_plans_organization({ transaction }), - organizations.getInvoices_organization({ transaction }), - organizations.getPayments_organization({ transaction }), organizations.getAssessments_organization({ transaction }), organizations.getAssessment_results_organization({ transaction }), organizations.getMessages_organization({ transaction }), organizations.getMessage_recipients_organization({ transaction }), - organizations.getDocuments_organization({ transaction }), ]); output.users_organizations = users_organizations; output.campuses_organization = campuses_organization; output.academic_years_organization = academic_years_organization; output.grades_organization = grades_organization; output.subjects_organization = subjects_organization; - output.students_organization = students_organization; - output.guardians_organization = guardians_organization; output.staff_organization = staff_organization; output.classes_organization = classes_organization; output.class_enrollments_organization = class_enrollments_organization; @@ -187,14 +173,10 @@ class OrganizationsDBApi { output.timetable_periods_organization = timetable_periods_organization; output.attendance_sessions_organization = attendance_sessions_organization; output.attendance_records_organization = attendance_records_organization; - output.fee_plans_organization = fee_plans_organization; - output.invoices_organization = invoices_organization; - output.payments_organization = payments_organization; output.assessments_organization = assessments_organization; output.assessment_results_organization = assessment_results_organization; output.messages_organization = messages_organization; output.message_recipients_organization = message_recipients_organization; - output.documents_organization = documents_organization; return output; } diff --git a/backend/src/db/api/payments.ts b/backend/src/db/api/payments.ts deleted file mode 100644 index cbfdc50..0000000 --- a/backend/src/db/api/payments.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 { Payments } from '@/db/models/payments'; -import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; - -type PaymentsData = Partial> & { - organization?: string | null; - invoice?: string | null; - received_by?: string | null; - proof?: FileInput | FileInput[] | null; -}; - -interface PaymentsFilter { - limit?: number | string; - page?: number | string; - id?: string; - receipt_number?: string; - reference_code?: string; - notes?: string; - paid_atRange?: Array; - amountRange?: Array; - active?: boolean | string; - method?: string; - invoice?: string; - received_by?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -function paymentsTableName(): string { - const name = db.payments.getTableName(); - return typeof name === 'string' ? name : name.tableName; -} - -class PaymentsDBApi { - static async create( - data: PaymentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const payments = await db.payments.create( - { - id: data.id || undefined, - receipt_number: data.receipt_number || null, - paid_at: data.paid_at || null, - amount: data.amount || null, - method: data.method || null, - reference_code: data.reference_code || null, - notes: data.notes || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await payments.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await payments.setInvoice(data.invoice ?? undefined, { transaction }); - await payments.setReceived_by(data.received_by ?? undefined, { - transaction, - }); - - await FileDBApi.replaceRelationFiles( - { - belongsTo: paymentsTableName(), - belongsToColumn: 'proof', - belongsToId: payments.id, - }, - data.proof, - options, - ); - - return payments; - } - - static async bulkImport( - data: PaymentsData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const paymentsData = data.map((item, index) => ({ - id: item.id || undefined, - receipt_number: item.receipt_number || null, - paid_at: item.paid_at || null, - amount: item.amount || null, - method: item.method || null, - reference_code: item.reference_code || null, - notes: item.notes || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - const payments = await db.payments.bulkCreate(paymentsData, { transaction }); - - for (let i = 0; i < payments.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: paymentsTableName(), - belongsToColumn: 'proof', - belongsToId: payments[i].id, - }, - data[i].proof, - options, - ); - } - - return payments; - } - - static async update( - id: string, - data: PaymentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const payments = await db.payments.findByPk(id, { transaction }); - - if (!payments) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.receipt_number !== undefined) - updatePayload.receipt_number = data.receipt_number; - if (data.paid_at !== undefined) updatePayload.paid_at = data.paid_at; - if (data.amount !== undefined) updatePayload.amount = data.amount; - if (data.method !== undefined) updatePayload.method = data.method; - if (data.reference_code !== undefined) - updatePayload.reference_code = data.reference_code; - if (data.notes !== undefined) updatePayload.notes = data.notes; - - updatePayload.updatedById = currentUser.id; - - await payments.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await payments.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.invoice !== undefined) { - await payments.setInvoice(data.invoice ?? undefined, { transaction }); - } - if (data.received_by !== undefined) { - await payments.setReceived_by(data.received_by ?? undefined, { - transaction, - }); - } - - await FileDBApi.replaceRelationFiles( - { - belongsTo: paymentsTableName(), - belongsToColumn: 'proof', - belongsToId: payments.id, - }, - data.proof, - options, - ); - - return payments; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.payments, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.payments, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const payments = await db.payments.findOne({ where, transaction }); - - if (!payments) { - return null; - } - - const output: Record = payments.get({ plain: true }); - - const [organization, invoice, received_by, proof] = await Promise.all([ - payments.getOrganization({ transaction }), - payments.getInvoice({ transaction }), - payments.getReceived_by({ transaction }), - payments.getProof({ transaction }), - ]); - output.organization = organization; - output.invoice = invoice; - output.received_by = received_by; - output.proof = proof; - - return output; - } - - static async findAll( - filter: PaymentsFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Payments[]; 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.invoices, - as: 'invoice', - where: filter.invoice - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.invoice.split('|').map((t) => Utils.uuid(t)), - }, - }, - { - invoice_number: { - [Op.or]: filter.invoice - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { - model: db.staff, - as: 'received_by', - where: filter.received_by - ? { - [Op.or]: [ - { - id: { - [Op.in]: filter.received_by - .split('|') - .map((t) => Utils.uuid(t)), - }, - }, - { - employee_number: { - [Op.or]: filter.received_by - .split('|') - .map((t) => ({ [Op.iLike]: `%${t}%` })), - }, - }, - ], - } - : {}, - }, - { model: db.file, as: 'proof' }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.receipt_number) { - where = { - ...where, - [Op.and]: Utils.ilike('payments', 'receipt_number', filter.receipt_number), - }; - } - if (filter.reference_code) { - where = { - ...where, - [Op.and]: Utils.ilike('payments', 'reference_code', filter.reference_code), - }; - } - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike('payments', 'notes', filter.notes), - }; - } - if (filter.paid_atRange) { - const [start, end] = filter.paid_atRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, paid_at: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - paid_at: { - ...(typeof where.paid_at === 'object' ? where.paid_at : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.amountRange) { - const [start, end] = filter.amountRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, amount: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - amount: { - ...(typeof where.amount === 'object' ? where.amount : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.method) { - where = { ...where, method: filter.method }; - } - 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.payments.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.payments, - 'receipt_number', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default PaymentsDBApi; diff --git a/backend/src/db/api/policy_documents.ts b/backend/src/db/api/policy_documents.ts new file mode 100644 index 0000000..3f5f2af --- /dev/null +++ b/backend/src/db/api/policy_documents.ts @@ -0,0 +1,298 @@ +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 { + isPolicyDocumentCategory, + nextPolicyDocumentVersion, + type PolicyDocumentCategory, +} from '@/shared/constants/policy-documents'; +import ValidationError from '@/shared/errors/validation'; +import { formatPersonName } from '@/shared/constants/users'; +import Utils from '@/db/utils'; +import type { PolicyDocuments } from '@/db/models/policy_documents'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +function requireCategory(value: unknown): PolicyDocumentCategory { + if (!isPolicyDocumentCategory(value)) { + throw new ValidationError(); + } + return value; +} + +type PolicyDocumentsData = Partial> & { + organization?: string | null; + campus?: string | null; +}; + +interface PolicyDocumentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + title?: string; + category?: string; + tag?: string; + active?: boolean | string; + organization?: string; + campus?: string; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +/** Display name of the user who created the entry (shown as the doc author). */ +function authorNameOf(currentUser: CurrentUser): string | null { + const fullName = formatPersonName( + currentUser.name_prefix, + currentUser.firstName, + currentUser.lastName, + ); + return fullName || currentUser.email || null; +} + +class Policy_documentsDBApi { + static async create( + data: PolicyDocumentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const record = await db.policy_documents.create( + { + id: data.id || undefined, + title: data.title ?? '', + body: data.body ?? null, + category: requireCategory(data.category), + tag: data.tag ?? null, + // Author is the creating user's name (set once at creation). + author: data.author ?? authorNameOf(currentUser), + steps: data.steps ?? null, + autism_considerations: data.autism_considerations ?? null, + version: data.version ?? 1, + active: data.active ?? true, + importHash: data.importHash || null, + organizationId: currentUser.organizationId ?? null, + campusId: data.campus ?? currentUser.campusId ?? null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return record; + } + + static async bulkImport( + data: PolicyDocumentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const rows = data.map((item, index) => ({ + id: item.id || undefined, + title: item.title ?? '', + body: item.body ?? null, + category: requireCategory(item.category), + tag: item.tag ?? null, + author: item.author ?? authorNameOf(currentUser), + steps: item.steps ?? null, + autism_considerations: item.autism_considerations ?? null, + version: item.version ?? 1, + active: item.active ?? true, + importHash: item.importHash || null, + organizationId: currentUser.organizationId ?? null, + campusId: item.campus ?? currentUser.campusId ?? null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.policy_documents.bulkCreate(rows, { transaction }); + } + + static async update( + id: string, + data: PolicyDocumentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const record = await findOwnedByPk(db.policy_documents, id, options); + if (!record) { + return null; + } + + const updatePayload: Partial> = {}; + if (data.title !== undefined) updatePayload.title = data.title; + if (data.body !== undefined) updatePayload.body = data.body; + if (data.category !== undefined) updatePayload.category = data.category; + if (data.tag !== undefined) updatePayload.tag = data.tag; + if (data.steps !== undefined) updatePayload.steps = data.steps; + if (data.autism_considerations !== undefined) + updatePayload.autism_considerations = data.autism_considerations; + if (data.active !== undefined) updatePayload.active = data.active; + // `author` is not changed on update — it records the original creator. + // Editing the content bumps the version, forcing re-acknowledgment. + const contentChanged = + data.title !== undefined || + data.body !== undefined || + data.steps !== undefined || + data.autism_considerations !== undefined; + const next = nextPolicyDocumentVersion( + record.version ?? 1, + contentChanged, + data.version, + ); + if (next !== (record.version ?? 1)) { + updatePayload.version = next; + } + updatePayload.updatedById = currentUser.id; + + await record.update(updatePayload, { transaction }); + return record; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.policy_documents, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.policy_documents, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const record = await db.policy_documents.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); + if (!record) { + return null; + } + + const output: Record = record.get({ plain: true }); + const [organization, campus] = await Promise.all([ + record.getOrganization({ transaction }), + record.getCampus({ transaction }), + ]); + output.organization = organization; + output.campus = campus; + return output; + } + + static async findAll( + filter: PolicyDocumentsFilter, + 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; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.title) { + where = { + ...where, + [Op.and]: Utils.ilike('policy_documents', 'title', filter.title), + }; + } + if (filter.category) { + where = { ...where, category: filter.category }; + } + if (filter.tag) { + where = { ...where, tag: filter.tag }; + } + if (filter.active !== undefined) { + where = { + ...where, + 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 + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.policy_documents.findAndCountAll({ + where, + include: [ + { model: db.organizations, as: 'organization' }, + { model: db.campuses, as: 'campus' }, + ], + 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.policy_documents, + 'title', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Policy_documentsDBApi; diff --git a/backend/src/db/api/roles.ts b/backend/src/db/api/roles.ts index 41897d5..3ad98ed 100644 --- a/backend/src/db/api/roles.ts +++ b/backend/src/db/api/roles.ts @@ -48,6 +48,7 @@ class RolesDBApi { { id: data.id || undefined, name: data.name || null, + scope: data.scope, globalAccess: data.globalAccess || false, importHash: data.importHash || null, createdById: currentUser.id, @@ -71,6 +72,7 @@ class RolesDBApi { const rolesData = data.map((item, index) => ({ id: item.id || undefined, name: item.name || null, + scope: item.scope, globalAccess: item.globalAccess || false, importHash: item.importHash || null, createdById: currentUser.id, @@ -98,6 +100,7 @@ class RolesDBApi { const updatePayload: Partial> = {}; if (data.name !== undefined) updatePayload.name = data.name; + if (data.scope !== undefined) updatePayload.scope = data.scope; if (data.globalAccess !== undefined) updatePayload.globalAccess = data.globalAccess; diff --git a/backend/src/db/api/shared/repository.test.ts b/backend/src/db/api/shared/repository.test.ts new file mode 100644 index 0000000..8c04895 --- /dev/null +++ b/backend/src/db/api/shared/repository.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tenantWhere } from '@/db/api/shared/repository'; +import type { CurrentUser } from '@/db/api/types'; + +/** + * Unit tests for pure repository helpers. + * + * Tests for `findOwnedByPk`, `deleteRecordsByIds`, and `autocompleteByField` + * require Sequelize model mocks which would need type assertions. Those + * functions are covered by integration tests instead. + */ + +const ORG_A = '11111111-1111-1111-1111-111111111111'; +const ORG_B = '22222222-2222-2222-2222-222222222222'; + +const userOrgA: CurrentUser = { id: 'u1', organizationId: ORG_A }; +const globalUser: CurrentUser = { + id: 'g1', + organizationId: ORG_A, + app_role: { globalAccess: true }, +}; + +test('tenantWhere scopes a non-global user to their organization', () => { + assert.deepEqual(tenantWhere(userOrgA), { organizationId: ORG_A }); +}); + +test('tenantWhere returns no clause for a global-access user', () => { + assert.deepEqual(tenantWhere(globalUser), {}); +}); + +test('tenantWhere returns no clause when there is no user/org', () => { + assert.deepEqual(tenantWhere(undefined), {}); + assert.deepEqual(tenantWhere({ id: null }), {}); +}); + +test('tenantWhere prefers the loaded organizations.id over the scalar', () => { + const user: CurrentUser = { + id: 'u2', + organizations: { id: ORG_B }, + organizationId: ORG_A, + }; + assert.deepEqual(tenantWhere(user), { organizationId: ORG_B }); +}); diff --git a/backend/src/db/api/shared/repository.ts b/backend/src/db/api/shared/repository.ts index 670ab0a..0195c5b 100644 --- a/backend/src/db/api/shared/repository.ts +++ b/backend/src/db/api/shared/repository.ts @@ -5,7 +5,7 @@ import { type WhereOptions, } from 'sequelize'; import Utils from '@/db/utils'; -import type { DbApiOptions } from '@/db/api/types'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; /** * Shared generic-repository helpers for the `db/api` layer. They cover the @@ -13,16 +13,54 @@ import type { DbApiOptions } from '@/db/api/types'; * and the single-field autocomplete, parameterized by the model. Entity-specific * methods (`create`/`update`/`bulkImport`/`findBy`/`findAll`) stay in each * repository. + * + * Tenant scoping: mutations and id lookups must never cross organizations. The + * helpers derive the tenant clause from `options.currentUser` and add + * `organizationId` to the `where` for non-global users. Global-access users + * (system roles) and internal calls without an authenticated user (seeders, + * system jobs) are not org-restricted — matching `findAll`'s existing behavior. */ -/** Finds a record by id and soft-deletes it (returns null when absent). */ +/** + * The `{ organizationId }` clause to AND into a tenant-owned query, or `{}` when + * the caller is global-access or has no resolvable organization. Mirrors the + * scoping logic used by every entity `findAll`. + */ +export function tenantWhere(currentUser?: CurrentUser): { + organizationId?: string; +} { + const globalAccess = currentUser?.app_role?.globalAccess === true; + const organizationId = + currentUser?.organizations?.id ?? currentUser?.organizationId ?? null; + if (globalAccess || !organizationId) { + return {}; + } + return { organizationId }; +} + +/** + * Finds a record by id, scoped to the caller's tenant. Returns null when the row + * is absent OR belongs to another organization (cross-tenant ids are + * indistinguishable from missing). Use in place of `model.findByPk(id)` for the + * lookup that precedes a tenant-owned update/remove/read-by-id. + */ +export async function findOwnedByPk( + model: ModelStatic, + id: string, + options?: DbApiOptions, +): Promise { + const where: WhereOptions = { id, ...tenantWhere(options?.currentUser) }; + return model.findOne({ where, transaction: options?.transaction }); +} + +/** Finds a record by id and soft-deletes it, scoped to the caller's tenant. */ export async function removeRecord( model: ModelStatic, id: string, options?: DbApiOptions, ): Promise { const transaction = options?.transaction; - const record = await model.findByPk(id, { transaction }); + const record = await findOwnedByPk(model, id, options); if (!record) { return null; @@ -32,14 +70,20 @@ export async function removeRecord( return record; } -/** Deletes every record whose id is in `ids` (within the caller's transaction). */ +/** + * Deletes every record whose id is in `ids` and belongs to the caller's tenant + * (within the caller's transaction). Cross-tenant ids are silently skipped. + */ export async function deleteRecordsByIds( model: ModelStatic, ids: string[], options?: DbApiOptions, ): Promise { const transaction = options?.transaction; - const where: WhereOptions = { id: { [Op.in]: ids } }; + const where: WhereOptions = { + id: { [Op.in]: ids }, + ...tenantWhere(options?.currentUser), + }; const records = await model.findAll({ where, transaction }); @@ -63,17 +107,20 @@ export async function autocompleteByField( globalAccess: boolean, organizationId: string | undefined, ): Promise> { - let where: WhereOptions = {}; + // Tenant scope (kept even when a query is present — see G3 in the + // tenant-isolation audit, where the query branch used to overwrite it). + const tenant: WhereOptions = + !globalAccess && organizationId ? { organizationId } : {}; - if (!globalAccess && organizationId) { - where = { organizationId }; - } - - if (query) { - where = { - [Op.or]: [{ id: Utils.uuid(query) }, Utils.ilike(model.name, field, query)], - }; - } + const where: WhereOptions = query + ? { + ...tenant, + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike(model.name, field, query), + ], + } + : tenant; const records = await model.findAll({ attributes: ['id', field], diff --git a/backend/src/db/api/staff.ts b/backend/src/db/api/staff.ts index e178acf..861ba96 100644 --- a/backend/src/db/api/staff.ts +++ b/backend/src/db/api/staff.ts @@ -10,6 +10,8 @@ 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'; @@ -138,7 +140,7 @@ class StaffDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const staff = await db.staff.findByPk(id, { transaction }); + const staff = await findOwnedByPk(db.staff, id, options); if (!staff) { return null; @@ -204,7 +206,10 @@ class StaffDBApi { ): Promise | null> { const transaction = options?.transaction; - const staff = await db.staff.findOne({ where, transaction }); + const staff = await db.staff.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!staff) { return null; @@ -216,7 +221,6 @@ class StaffDBApi { classes_homeroom_teacher, class_subjects_teacher, attendance_sessions_taken_by, - payments_received_by, organization, campus, user, @@ -225,7 +229,6 @@ class StaffDBApi { staff.getClasses_homeroom_teacher({ transaction }), staff.getClass_subjects_teacher({ transaction }), staff.getAttendance_sessions_taken_by({ transaction }), - staff.getPayments_received_by({ transaction }), staff.getOrganization({ transaction }), staff.getCampus({ transaction }), staff.getUser({ transaction }), @@ -234,7 +237,6 @@ class StaffDBApi { output.classes_homeroom_teacher = classes_homeroom_teacher; output.class_subjects_teacher = class_subjects_teacher; output.attendance_sessions_taken_by = attendance_sessions_taken_by; - output.payments_received_by = payments_received_by; output.organization = organization; output.campus = campus; output.user = user; diff --git a/backend/src/db/api/students.ts b/backend/src/db/api/students.ts deleted file mode 100644 index cdfce1d..0000000 --- a/backend/src/db/api/students.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { - Op, - type Includeable, - type InferAttributes, - type InferCreationAttributes, - type WhereAttributeHash, -} from 'sequelize'; -import db from '@/db/models'; -import { - removeRecord, - deleteRecordsByIds, - autocompleteByField, -} 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 { Students } from '@/db/models/students'; -import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; - -type StudentsData = Partial> & { - organization?: string | null; - campus?: string | null; - photo?: FileInput | FileInput[] | null; -}; - -interface StudentsFilter { - limit?: number | string; - page?: number | string; - id?: string; - student_number?: string; - first_name?: string; - last_name?: string; - email?: string; - phone?: string; - address?: string; - date_of_birthRange?: Array; - enrollment_dateRange?: Array; - active?: boolean | string; - gender?: string; - status?: string; - campus?: string; - organization?: string; - createdAtRange?: Array; - field?: string; - sort?: string; -} - -const NO_USER: CurrentUser = { id: null }; - -function studentsTableName(): string { - const name = db.students.getTableName(); - return typeof name === 'string' ? name : name.tableName; -} - -class StudentsDBApi { - static async create( - data: StudentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const students = await db.students.create( - { - id: data.id || undefined, - student_number: data.student_number || null, - first_name: data.first_name || null, - last_name: data.last_name || null, - gender: data.gender || null, - date_of_birth: data.date_of_birth || null, - enrollment_date: data.enrollment_date || null, - status: data.status || null, - email: data.email || null, - phone: data.phone || null, - address: data.address || null, - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await students.setOrganization(currentUser.organizationId ?? undefined, { - transaction, - }); - await students.setCampus(data.campus ?? undefined, { transaction }); - - await FileDBApi.replaceRelationFiles( - { - belongsTo: studentsTableName(), - belongsToColumn: 'photo', - belongsToId: students.id, - }, - data.photo, - options, - ); - - return students; - } - - static async bulkImport( - data: StudentsData[], - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - - const studentsData = data.map((item, index) => ({ - id: item.id || undefined, - student_number: item.student_number || null, - first_name: item.first_name || null, - last_name: item.last_name || null, - gender: item.gender || null, - date_of_birth: item.date_of_birth || null, - enrollment_date: item.enrollment_date || null, - status: item.status || null, - email: item.email || null, - phone: item.phone || null, - address: item.address || null, - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), - })); - - const students = await db.students.bulkCreate(studentsData, { transaction }); - - for (let i = 0; i < students.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: studentsTableName(), - belongsToColumn: 'photo', - belongsToId: students[i].id, - }, - data[i].photo, - options, - ); - } - - return students; - } - - static async update( - id: string, - data: StudentsData, - options?: DbApiOptions, - ): Promise { - const currentUser = options?.currentUser ?? NO_USER; - const transaction = options?.transaction; - const globalAccess = currentUser.app_role?.globalAccess; - - const students = await db.students.findByPk(id, { transaction }); - - if (!students) { - return null; - } - - const updatePayload: Partial> = {}; - - if (data.student_number !== undefined) - updatePayload.student_number = data.student_number; - if (data.first_name !== undefined) - updatePayload.first_name = data.first_name; - if (data.last_name !== undefined) updatePayload.last_name = data.last_name; - if (data.gender !== undefined) updatePayload.gender = data.gender; - if (data.date_of_birth !== undefined) - updatePayload.date_of_birth = data.date_of_birth; - if (data.enrollment_date !== undefined) - updatePayload.enrollment_date = data.enrollment_date; - if (data.status !== undefined) updatePayload.status = data.status; - if (data.email !== undefined) updatePayload.email = data.email; - if (data.phone !== undefined) updatePayload.phone = data.phone; - if (data.address !== undefined) updatePayload.address = data.address; - - updatePayload.updatedById = currentUser.id; - - await students.update(updatePayload, { transaction }); - - if (data.organization !== undefined) { - const orgId = globalAccess - ? data.organization - : currentUser.organizationId; - await students.setOrganization(orgId ?? undefined, { transaction }); - } - if (data.campus !== undefined) { - await students.setCampus(data.campus ?? undefined, { transaction }); - } - - await FileDBApi.replaceRelationFiles( - { - belongsTo: studentsTableName(), - belongsToColumn: 'photo', - belongsToId: students.id, - }, - data.photo, - options, - ); - - return students; - } - - static async deleteByIds( - ids: string[], - options?: DbApiOptions, - ): Promise { - return deleteRecordsByIds(db.students, ids, options); - } - - static async remove( - id: string, - options?: DbApiOptions, - ): Promise { - return removeRecord(db.students, id, options); - } - - static async findBy( - where: WhereAttributeHash, - options?: DbApiOptions, - ): Promise | null> { - const transaction = options?.transaction; - - const students = await db.students.findOne({ where, transaction }); - - if (!students) { - return null; - } - - const output: Record = students.get({ plain: true }); - - const [ - guardians_student, - class_enrollments_student, - attendance_records_student, - invoices_student, - assessment_results_student, - organization, - campus, - photo, - ] = await Promise.all([ - students.getGuardians_student({ transaction }), - students.getClass_enrollments_student({ transaction }), - students.getAttendance_records_student({ transaction }), - students.getInvoices_student({ transaction }), - students.getAssessment_results_student({ transaction }), - students.getOrganization({ transaction }), - students.getCampus({ transaction }), - students.getPhoto({ transaction }), - ]); - output.guardians_student = guardians_student; - output.class_enrollments_student = class_enrollments_student; - output.attendance_records_student = attendance_records_student; - output.invoices_student = invoices_student; - output.assessment_results_student = assessment_results_student; - output.organization = organization; - output.campus = campus; - output.photo = photo; - - return output; - } - - static async findAll( - filter: StudentsFilter, - globalAccess: boolean, - options?: DbApiOptions, - ): Promise<{ rows: Students[]; 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.file, as: 'photo' }, - ]; - - if (filter.id) { - where = { ...where, id: Utils.uuid(filter.id) }; - } - if (filter.student_number) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'student_number', filter.student_number), - }; - } - if (filter.first_name) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'first_name', filter.first_name), - }; - } - if (filter.last_name) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'last_name', filter.last_name), - }; - } - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'email', filter.email), - }; - } - if (filter.phone) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'phone', filter.phone), - }; - } - if (filter.address) { - where = { - ...where, - [Op.and]: Utils.ilike('students', 'address', filter.address), - }; - } - if (filter.date_of_birthRange) { - const [start, end] = filter.date_of_birthRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, date_of_birth: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - date_of_birth: { - ...(typeof where.date_of_birth === 'object' - ? where.date_of_birth - : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.enrollment_dateRange) { - const [start, end] = filter.enrollment_dateRange; - if (start !== undefined && start !== null && start !== '') { - where = { ...where, enrollment_date: { [Op.gte]: start } }; - } - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - enrollment_date: { - ...(typeof where.enrollment_date === 'object' - ? where.enrollment_date - : {}), - [Op.lte]: end, - }, - }; - } - } - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true', - }; - } - if (filter.gender) { - where = { ...where, gender: filter.gender }; - } - 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.students.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.students, - 'student_number', - query, - limit, - offset, - globalAccess, - organizationId, - ); - } -} - -export default StudentsDBApi; diff --git a/backend/src/db/api/subjects.ts b/backend/src/db/api/subjects.ts index 0900d90..dbcebba 100644 --- a/backend/src/db/api/subjects.ts +++ b/backend/src/db/api/subjects.ts @@ -9,6 +9,8 @@ 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'; @@ -94,7 +96,7 @@ class SubjectsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const subjects = await db.subjects.findByPk(id, { transaction }); + const subjects = await findOwnedByPk(db.subjects, id, options); if (!subjects) { return null; @@ -141,7 +143,10 @@ class SubjectsDBApi { ): Promise | null> { const transaction = options?.transaction; - const subjects = await db.subjects.findOne({ where, transaction }); + const subjects = await db.subjects.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!subjects) { return null; diff --git a/backend/src/db/api/timetable_periods.ts b/backend/src/db/api/timetable_periods.ts index 29009ec..70cc6f8 100644 --- a/backend/src/db/api/timetable_periods.ts +++ b/backend/src/db/api/timetable_periods.ts @@ -10,6 +10,8 @@ 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'; @@ -113,9 +115,7 @@ class Timetable_periodsDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const timetable_periods = await db.timetable_periods.findByPk(id, { - transaction, - }); + const timetable_periods = await findOwnedByPk(db.timetable_periods, id, options); if (!timetable_periods) { return null; @@ -176,7 +176,7 @@ class Timetable_periodsDBApi { const transaction = options?.transaction; const timetable_periods = await db.timetable_periods.findOne({ - where, + where: { ...where, ...tenantWhere(options?.currentUser) }, transaction, }); diff --git a/backend/src/db/api/timetables.ts b/backend/src/db/api/timetables.ts index f35a751..ab871a8 100644 --- a/backend/src/db/api/timetables.ts +++ b/backend/src/db/api/timetables.ts @@ -10,6 +10,8 @@ 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'; @@ -108,7 +110,7 @@ class TimetablesDBApi { const transaction = options?.transaction; const globalAccess = currentUser.app_role?.globalAccess; - const timetables = await db.timetables.findByPk(id, { transaction }); + const timetables = await findOwnedByPk(db.timetables, id, options); if (!timetables) { return null; @@ -165,7 +167,10 @@ class TimetablesDBApi { ): Promise | null> { const transaction = options?.transaction; - const timetables = await db.timetables.findOne({ where, transaction }); + const timetables = await db.timetables.findOne({ + where: { ...where, ...tenantWhere(options?.currentUser) }, + transaction, + }); if (!timetables) { return null; diff --git a/backend/src/db/api/types.ts b/backend/src/db/api/types.ts index 746f398..e312e8d 100644 --- a/backend/src/db/api/types.ts +++ b/backend/src/db/api/types.ts @@ -20,6 +20,7 @@ export interface PermissionLike { export interface UserProfileRecord { id: string; email: string; + name_prefix: string | null; firstName: string | null; lastName: string | null; organizationId: string | null; @@ -62,6 +63,7 @@ export interface CurrentUser { */ password?: string | null; custom_permissions?: PermissionLike[] | null; + name_prefix?: string | null; firstName?: string | null; lastName?: string | null; email?: string | null; diff --git a/backend/src/db/api/users.ts b/backend/src/db/api/users.ts index bf470e1..9e95534 100644 --- a/backend/src/db/api/users.ts +++ b/backend/src/db/api/users.ts @@ -14,7 +14,6 @@ import { } from '@/db/api/shared/repository'; import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; import { resolvePagination } from '@/shared/constants/pagination'; -import { SPECIAL_ROLE_NAMES } from '@/shared/constants/roles'; import { EMAIL_ACTION_TOKEN_BYTES, EMAIL_ACTION_TOKEN_TTL_MS, @@ -101,6 +100,7 @@ class UsersDBApi { const users = await db.users.create( { id: data.id || undefined, + name_prefix: data.name_prefix || null, firstName: data.firstName || null, lastName: data.lastName || null, phoneNumber: data.phoneNumber || null, @@ -115,20 +115,16 @@ class UsersDBApi { passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null, provider: data.provider || null, importHash: data.importHash || null, + campusId: data.campusId || null, createdById: currentUser.id, updatedById: currentUser.id, }, { transaction }, ); - if (!data.app_role) { - const role = await db.roles.findOne({ - where: { name: SPECIAL_ROLE_NAMES.DEFAULT_USER }, - }); - if (role) { - await users.setApp_role(role, { transaction }); - } - } else { + // Roles are assigned explicitly by the provisioning flow; a user created + // without one has no role and falls back to `guest` until assigned. + if (data.app_role) { await users.setApp_role(data.app_role, { transaction }); } @@ -161,6 +157,7 @@ class UsersDBApi { const usersData = data.map((item, index) => ({ id: item.id || undefined, + name_prefix: item.name_prefix || null, firstName: item.firstName || null, lastName: item.lastName || null, phoneNumber: item.phoneNumber || null, @@ -214,6 +211,8 @@ class UsersDBApi { const updatePayload: Partial> = {}; + if (data.name_prefix !== undefined) + updatePayload.name_prefix = data.name_prefix; if (data.firstName !== undefined) updatePayload.firstName = data.firstName; if (data.lastName !== undefined) updatePayload.lastName = data.lastName; if (data.phoneNumber !== undefined) @@ -234,6 +233,7 @@ class UsersDBApi { updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt; if (data.provider !== undefined) updatePayload.provider = data.provider; + if (data.campusId !== undefined) updatePayload.campusId = data.campusId; updatePayload.updatedById = currentUser.id; @@ -412,6 +412,7 @@ class UsersDBApi { return { id: user.id, email: user.email, + name_prefix: user.name_prefix ?? null, firstName: user.firstName, lastName: user.lastName, organizationId: user.organizationId, diff --git a/backend/src/db/cleanup-refresh-tokens.ts b/backend/src/db/cleanup-refresh-tokens.ts new file mode 100644 index 0000000..c799a69 --- /dev/null +++ b/backend/src/db/cleanup-refresh-tokens.ts @@ -0,0 +1,23 @@ +import db from '@/db/models'; +import { cleanupExpiredRefreshTokens } 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) + */ +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(); +} + +run().catch((error) => { + console.error('Refresh-token cleanup failed:', error); + process.exit(1); +}); diff --git a/backend/src/db/initial-schema.ts b/backend/src/db/initial-schema.ts index b376c94..66d23f2 100644 --- a/backend/src/db/initial-schema.ts +++ b/backend/src/db/initial-schema.ts @@ -25,18 +25,9 @@ CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, " 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 "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")); -DO 'BEGIN CREATE TYPE "public"."enum_documents_entity_type" AS ENUM(''student'', ''staff'', ''class'', ''invoice'', ''organization'', ''campus'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; -DO 'BEGIN CREATE TYPE "public"."enum_documents_category" AS ENUM(''policy'', ''report'', ''id'', ''medical'', ''consent'', ''invoice'', ''receipt'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "documents" ("id" UUID , "entity_type" "public"."enum_documents_entity_type", "entity_reference" TEXT, "name" TEXT, "category" "public"."enum_documents_category", "uploaded_at" TIMESTAMP WITH TIME ZONE, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "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_fee_plans_billing_cycle" AS ENUM(''one_time'', ''monthly'', ''termly'', ''annual''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "fee_plans" ("id" UUID , "name" TEXT, "billing_cycle" "public"."enum_fee_plans_billing_cycle", "total_amount" DECIMAL, "active" BOOLEAN NOT NULL DEFAULT false, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "organizationId" UUID, "gradeId" 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 "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")); CREATE TABLE IF NOT EXISTS "grades" ("id" UUID , "name" TEXT, "code" TEXT, "sort_order" INTEGER, "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")); -DO 'BEGIN CREATE TYPE "public"."enum_guardians_relationship" AS ENUM(''mother'', ''father'', ''guardian'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "guardians" ("id" UUID , "full_name" TEXT, "relationship" "public"."enum_guardians_relationship", "phone" TEXT, "email" TEXT, "address" TEXT, "primary_contact" 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, "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")); -DO 'BEGIN CREATE TYPE "public"."enum_invoices_status" AS ENUM(''draft'', ''issued'', ''partially_paid'', ''paid'', ''overdue'', ''void''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "invoices" ("id" UUID , "invoice_number" TEXT, "issue_date" TIMESTAMP WITH TIME ZONE, "due_date" TIMESTAMP WITH TIME ZONE, "subtotal" DECIMAL, "discount_amount" DECIMAL, "tax_amount" DECIMAL, "total_amount" DECIMAL, "balance_due" DECIMAL, "status" "public"."enum_invoices_status", "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "fee_planId" UUID, "organizationId" 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")); DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_recipient_type" AS ENUM(''user'', ''student'', ''guardian''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_delivery_status" AS ENUM(''pending'', ''sent'', ''delivered'', ''failed'', ''read''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "message_recipients" ("id" UUID , "recipient_type" "public"."enum_message_recipients_recipient_type", "recipient_label" TEXT, "destination" TEXT, "delivery_status" "public"."enum_message_recipients_delivery_status", "delivered_at" TIMESTAMP WITH TIME ZONE, "read_at" TIMESTAMP WITH TIME ZONE, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "messageId" 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")); @@ -45,8 +36,6 @@ DO 'BEGIN CREATE TYPE "public"."enum_messages_audience" AS ENUM(''all_org'', ''c DO 'BEGIN CREATE TYPE "public"."enum_messages_status" AS ENUM(''draft'', ''scheduled'', ''sent'', ''failed''); EXCEPTION WHEN duplicate_object THEN null; END'; CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" 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 "organizations" ("id" UUID , "name" TEXT, "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")); -DO 'BEGIN CREATE TYPE "public"."enum_payments_method" AS ENUM(''cash'', ''bank_transfer'', ''card'', ''mobile_money'', ''cheque'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "payments" ("id" UUID , "receipt_number" TEXT, "paid_at" TIMESTAMP WITH TIME ZONE, "amount" DECIMAL, "method" "public"."enum_payments_method", "reference_code" TEXT, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "invoiceId" UUID, "organizationId" UUID, "received_byId" 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 "permissions" ("id" UUID , "name" TEXT, "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 "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")); @@ -56,9 +45,6 @@ DO 'BEGIN CREATE TYPE "public"."enum_staff_status" AS ENUM(''active'', ''on_leav 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")); -DO 'BEGIN CREATE TYPE "public"."enum_students_gender" AS ENUM(''male'', ''female'', ''other'', ''prefer_not_to_say''); EXCEPTION WHEN duplicate_object THEN null; END'; -DO 'BEGIN CREATE TYPE "public"."enum_students_status" AS ENUM(''prospect'', ''enrolled'', ''inactive'', ''graduated'', ''transferred''); EXCEPTION WHEN duplicate_object THEN null; END'; -CREATE TABLE IF NOT EXISTS "students" ("id" UUID , "student_number" TEXT, "first_name" TEXT, "last_name" TEXT, "gender" "public"."enum_students_gender", "date_of_birth" TIMESTAMP WITH TIME ZONE, "enrollment_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_students_status", "email" TEXT, "phone" TEXT, "address" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "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")); 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")); DO 'BEGIN CREATE TYPE "public"."enum_timetable_periods_day_of_week" AS ENUM(''monday'', ''tuesday'', ''wednesday'', ''thursday'', ''friday'', ''saturday'', ''sunday''); EXCEPTION WHEN duplicate_object THEN null; END'; 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")); @@ -79,24 +65,18 @@ DROP TABLE IF EXISTS "user_progress" CASCADE; DROP TABLE IF EXISTS "timetables" CASCADE; DROP TABLE IF EXISTS "timetable_periods" CASCADE; DROP TABLE IF EXISTS "subjects" CASCADE; -DROP TABLE IF EXISTS "students" 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; DROP TABLE IF EXISTS "permissions" CASCADE; -DROP TABLE IF EXISTS "payments" CASCADE; DROP TABLE IF EXISTS "organizations" CASCADE; DROP TABLE IF EXISTS "messages" CASCADE; DROP TABLE IF EXISTS "message_recipients" CASCADE; -DROP TABLE IF EXISTS "invoices" CASCADE; -DROP TABLE IF EXISTS "guardians" CASCADE; DROP TABLE IF EXISTS "grades" CASCADE; DROP TABLE IF EXISTS "frame_entries" CASCADE; DROP TABLE IF EXISTS "files" CASCADE; -DROP TABLE IF EXISTS "fee_plans" CASCADE; -DROP TABLE IF EXISTS "documents" CASCADE; DROP TABLE IF EXISTS "content_catalog" CASCADE; DROP TABLE IF EXISTS "communication_events" CASCADE; DROP TABLE IF EXISTS "classes" CASCADE; @@ -121,22 +101,14 @@ DROP TYPE IF EXISTS "public"."enum_class_enrollments_status"; DROP TYPE IF EXISTS "public"."enum_class_subjects_status"; DROP TYPE IF EXISTS "public"."enum_classes_status"; DROP TYPE IF EXISTS "public"."enum_communication_events_event_type"; -DROP TYPE IF EXISTS "public"."enum_documents_entity_type"; -DROP TYPE IF EXISTS "public"."enum_documents_category"; -DROP TYPE IF EXISTS "public"."enum_fee_plans_billing_cycle"; -DROP TYPE IF EXISTS "public"."enum_guardians_relationship"; -DROP TYPE IF EXISTS "public"."enum_invoices_status"; DROP TYPE IF EXISTS "public"."enum_message_recipients_recipient_type"; 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_payments_method"; 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_students_gender"; -DROP TYPE IF EXISTS "public"."enum_students_status"; DROP TYPE IF EXISTS "public"."enum_timetable_periods_day_of_week"; DROP TYPE IF EXISTS "public"."enum_timetables_status"; DROP TYPE IF EXISTS "public"."enum_user_progress_progress_type"; 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 new file mode 100644 index 0000000..fb5ac78 --- /dev/null +++ b/backend/src/db/migrations/20260610010000-add-role-scope-and-user-campus.ts @@ -0,0 +1,35 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; +import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles'; + +/** + * Workstream 3 §3.1 foundation: add the authorization `scope` to `roles` (NOT + * NULL — every role has a scope; the 11 first-class roles are seeded with one) + * and a nullable `campusId` to `users` (campus scope for campus-bound roles; + * null for system/organization scopes, so legitimately optional). + * + * Pre-launch with no production data, the `scope` column is added NOT NULL + * against the (empty, freshly-migrated) `roles` table; seeders populate it. + * `campusId` is a plain UUID (no DB-level FK), matching how + * `users.organizationId` is modeled — associations use `constraints: false`. + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.addColumn('roles', 'scope', { + type: DataTypes.ENUM(...ROLE_SCOPE_VALUES), + allowNull: false, + }); + await queryInterface.addColumn('users', 'campusId', { + type: DataTypes.UUID, + allowNull: true, + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.removeColumn('users', 'campusId'); + await queryInterface.removeColumn('roles', 'scope'); + // Postgres keeps the enum type after the column is dropped; remove it too. + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_roles_scope";', + ); + }, +}; diff --git a/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts b/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts new file mode 100644 index 0000000..3089a3c --- /dev/null +++ b/backend/src/db/migrations/20260611000000-policy-documents-and-acknowledgments.ts @@ -0,0 +1,83 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; +import { POLICY_DOCUMENT_CATEGORY_VALUES } from '@/shared/constants/policy-documents'; + +/** + * Workstream 11 — policy/safety acknowledgment persistence. + * + * `policy_documents`: tenant/campus-scoped documents (safety protocols + handbook + * policies) that director/office_manager manage. `version` bumps on change so a + * new version requires re-acknowledgment. + * + * `policy_acknowledgments`: one row per (user, document, version) — a campus + * staff member's acknowledgment of a specific document version. Unique on + * (userId, policyDocumentId, version). Plain UUID references (no DB-level FK), + * matching the rest of the schema (associations use `constraints: false`). + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.createTable('policy_documents', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { type: DataTypes.TEXT, allowNull: false }, + body: { type: DataTypes.TEXT, allowNull: true }, + category: { + type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES), + allowNull: false, + }, + // Optional finer sub-category within a category (e.g. the Handbook & + // Policies page's Operations/Behavior/Safety/Communication/Legal tag). + tag: { type: DataTypes.STRING(255), allowNull: true }, + // Display name of the staff member who created the entry (shown as "by …"). + author: { type: DataTypes.STRING(255), allowNull: true }, + // Author-filled structured content (safety protocols): ordered procedure + // steps and autism-specific considerations. Null for handbook policies. + steps: { type: DataTypes.JSONB, allowNull: true }, + autism_considerations: { type: DataTypes.JSONB, allowNull: true }, + version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 }, + active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true }, + organizationId: { 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 }, + }); + + await queryInterface.createTable('policy_acknowledgments', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + policyDocumentId: { type: DataTypes.UUID, allowNull: false }, + version: { type: DataTypes.INTEGER, allowNull: false }, + userId: { type: DataTypes.UUID, allowNull: false }, + acknowledgedAt: { type: DataTypes.DATE, allowNull: false }, + organizationId: { 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 }, + }); + + await queryInterface.addIndex('policy_acknowledgments', { + fields: ['userId', 'policyDocumentId', 'version'], + unique: true, + name: 'policy_acknowledgments_user_document_version_unique', + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable('policy_acknowledgments'); + await queryInterface.dropTable('policy_documents'); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_policy_documents_category";', + ); + }, +}; diff --git a/backend/src/db/migrations/20260611010000-audio-files.ts b/backend/src/db/migrations/20260611010000-audio-files.ts new file mode 100644 index 0000000..9969e08 --- /dev/null +++ b/backend/src/db/migrations/20260611010000-audio-files.ts @@ -0,0 +1,46 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Workstream 13 — audio library. `director`/`office_manager`/`teacher` upload + * audio files that any campus staff can pick to play in the classroom timer. + * + * Uploaded files are **campus-scoped** (`organizationId` + `campusId` set). The + * existing built-in timer sounds remain the **global defaults** served from the + * (global) `content_catalog`, so they are already available to every org and are + * not duplicated here. `is_default` + a null `organizationId` are kept so a + * platform admin could later add global audio rows; campus uploads are not + * default. + * + * The binary itself is stored via the existing JWT-authenticated file subsystem + * (`POST /api/file/upload/...`); `url` holds the returned reference. + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.createTable('audio_files', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { type: DataTypes.TEXT, allowNull: false }, + url: { type: DataTypes.STRING(2083), allowNull: false }, + is_default: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true }, + organizationId: { 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 }, + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable('audio_files'); + }, +}; diff --git a/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts b/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts new file mode 100644 index 0000000..3d47d1f --- /dev/null +++ b/backend/src/db/migrations/20260611040000-add-user-name-prefix.ts @@ -0,0 +1,23 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; +import { USER_NAME_PREFIX_VALUES } from '@/shared/constants/users'; + +/** + * Add an honorific name prefix (title) to users — `Mr.` / `Ms.` / `Dr.` etc. + * Lets the UI render "Dr. Williams" without the title being baked into the + * person's first name. Nullable; no production data (pre-launch reset). + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.addColumn('users', 'name_prefix', { + type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES), + allowNull: true, + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.removeColumn('users', 'name_prefix'); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_users_name_prefix";', + ); + }, +}; diff --git a/backend/src/db/migrations/20260611060000-audio-files-kinds.ts b/backend/src/db/migrations/20260611060000-audio-files-kinds.ts new file mode 100644 index 0000000..019a877 --- /dev/null +++ b/backend/src/db/migrations/20260611060000-audio-files-kinds.ts @@ -0,0 +1,44 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; +import { AUDIO_FILE_KINDS } from '@/shared/constants/audio-files'; + +/** + * Workstream 13 — generalize `audio_files` into a flexible sound library. A row + * is now one of three kinds (`file` | `url` | `recipe`): + * - `file` / `url` populate `url` (uploaded binary or external link); + * - `recipe` populates the new `recipe` JSONB (client-synthesized sound) and + * leaves `url` null — so `url` becomes nullable. + * + * `recipe` rows are played purely via the Web Audio API in the browser; they + * never reference the file subsystem and so are exempt from the download + * ownership check. + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.addColumn('audio_files', 'kind', { + type: DataTypes.ENUM(...AUDIO_FILE_KINDS), + allowNull: false, + defaultValue: 'file', + }); + await queryInterface.addColumn('audio_files', 'recipe', { + type: DataTypes.JSONB, + allowNull: true, + }); + await queryInterface.changeColumn('audio_files', 'url', { + type: DataTypes.STRING(2083), + allowNull: true, + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.changeColumn('audio_files', 'url', { + type: DataTypes.STRING(2083), + allowNull: false, + }); + await queryInterface.removeColumn('audio_files', 'recipe'); + await queryInterface.removeColumn('audio_files', 'kind'); + // Drop the enum type left behind by the ENUM column (Postgres). + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_audio_files_kind";', + ); + }, +}; diff --git a/backend/src/db/models/academic_years.ts b/backend/src/db/models/academic_years.ts index 46aaaae..a07e0f7 100644 --- a/backend/src/db/models/academic_years.ts +++ b/backend/src/db/models/academic_years.ts @@ -14,7 +14,6 @@ import type { HasManySetAssociationsMixin, } from 'sequelize'; import type { Classes } from './classes'; -import type { FeePlans } from './fee_plans'; import type { Organizations } from './organizations'; import type { Timetables } from './timetables'; import type { Users } from './users'; @@ -41,8 +40,6 @@ export class AcademicYears extends Model< declare setClasses_academic_year: HasManySetAssociationsMixin; declare getTimetables_academic_year: HasManyGetAssociationsMixin; declare setTimetables_academic_year: HasManySetAssociationsMixin; - declare getFee_plans_academic_year: HasManyGetAssociationsMixin; - declare setFee_plans_academic_year: HasManySetAssociationsMixin; declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -90,14 +87,6 @@ export class AcademicYears extends Model< - db.academic_years.hasMany(db.fee_plans, { - as: 'fee_plans_academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/assessment_results.ts b/backend/src/db/models/assessment_results.ts index cdb8085..8009477 100644 --- a/backend/src/db/models/assessment_results.ts +++ b/backend/src/db/models/assessment_results.ts @@ -13,7 +13,6 @@ import type { } from 'sequelize'; import type { Assessments } from './assessments'; import type { Organizations } from './organizations'; -import type { Students } from './students'; import type { Users } from './users'; export class AssessmentResults extends Model< @@ -39,8 +38,6 @@ export class AssessmentResults extends Model< declare setOrganization: BelongsToSetAssociationMixin; declare getAssessment: BelongsToGetAssociationMixin; declare setAssessment: BelongsToSetAssociationMixin; - declare getStudent: BelongsToGetAssociationMixin; - declare setStudent: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -99,14 +96,6 @@ export class AssessmentResults extends Model< constraints: false, }); - db.assessment_results.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/attendance_records.ts b/backend/src/db/models/attendance_records.ts index e72cc44..c5e4c7e 100644 --- a/backend/src/db/models/attendance_records.ts +++ b/backend/src/db/models/attendance_records.ts @@ -13,7 +13,6 @@ import type { } from 'sequelize'; import type { AttendanceSessions } from './attendance_sessions'; import type { Organizations } from './organizations'; -import type { Students } from './students'; import type { Users } from './users'; export class AttendanceRecords extends Model< @@ -39,8 +38,6 @@ export class AttendanceRecords extends Model< declare setOrganization: BelongsToSetAssociationMixin; declare getAttendance_session: BelongsToGetAssociationMixin; declare setAttendance_session: BelongsToSetAssociationMixin; - declare getStudent: BelongsToGetAssociationMixin; - declare setStudent: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -99,14 +96,6 @@ export class AttendanceRecords extends Model< constraints: false, }); - db.attendance_records.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/documents.ts b/backend/src/db/models/audio_files.ts similarity index 53% rename from backend/src/db/models/documents.ts rename to backend/src/db/models/audio_files.ts index 38584c2..914146e 100644 --- a/backend/src/db/models/documents.ts +++ b/backend/src/db/models/audio_files.ts @@ -10,245 +10,116 @@ import type { Db } from '@/db/types'; import type { BelongsToGetAssociationMixin, BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, } from 'sequelize'; -import type { Campuses } from './campuses'; -import type { File } from './file'; import type { Organizations } from './organizations'; +import type { Campuses } from './campuses'; import type { Users } from './users'; +import { + AUDIO_FILE_KINDS, + type AudioFileKind, +} from '@/shared/constants/audio-files'; -export class Documents extends Model< - InferAttributes, - InferCreationAttributes +/** Synthesis recipe stored on `recipe` rows (interpreted client-side). */ +export interface AudioRecipe { + readonly voices: ReadonlyArray<{ + readonly waveform: string; + readonly notes: ReadonlyArray<{ + readonly freq: number; + readonly startAt: number; + readonly duration: number; + readonly gain: number; + readonly attack?: number; + readonly rampFreqTo?: number; + }>; + }>; +} + +export class AudioFiles extends Model< + InferAttributes, + InferCreationAttributes > { declare id: CreationOptional; - declare entity_type: string | null; - declare entity_reference: string | null; - declare name: string | null; - declare category: string | null; - declare uploaded_at: Date | null; - declare notes: string | null; + declare title: string; + declare kind: CreationOptional; + declare url: CreationOptional; + declare recipe: CreationOptional; + declare is_default: CreationOptional; declare importHash: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; declare deletedAt: CreationOptional; - declare campusId: CreationOptional; - declare organizationId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; declare getCampus: BelongsToGetAssociationMixin; declare setCampus: BelongsToSetAssociationMixin; - declare getFile: HasManyGetAssociationsMixin; - declare setFile: 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.documents.belongsTo(db.organizations, { + db.audio_files.belongsTo(db.organizations, { as: 'organization', - foreignKey: { - name: 'organizationId', - }, + foreignKey: { name: 'organizationId' }, constraints: false, }); - - db.documents.belongsTo(db.campuses, { + db.audio_files.belongsTo(db.campuses, { as: 'campus', - foreignKey: { - name: 'campusId', - }, + foreignKey: { name: 'campusId' }, constraints: false, }); - - - - db.documents.hasMany(db.file, { - as: 'file', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.documents.getTableName(), - belongsToColumn: 'file', - }, - }); - - - db.documents.belongsTo(db.users, { - as: 'createdBy', - }); - - db.documents.belongsTo(db.users, { - as: 'updatedBy', - }); + db.audio_files.belongsTo(db.users, { as: 'createdBy' }); + db.audio_files.belongsTo(db.users, { as: 'updatedBy' }); } } -export default function (sequelize: Sequelize): typeof Documents { - Documents.init( +export default function (sequelize: Sequelize): typeof AudioFiles { + AudioFiles.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, }, - -entity_type: { - type: DataTypes.ENUM, - - - - values: [ - -"student", - - -"staff", - - -"class", - - -"invoice", - - -"organization", - - -"campus", - - -"other" - - ], - + title: { type: DataTypes.TEXT, allowNull: false }, + kind: { + type: DataTypes.ENUM(...AUDIO_FILE_KINDS), + allowNull: false, + defaultValue: 'file', }, - -entity_reference: { - type: DataTypes.TEXT, - - - + url: { type: DataTypes.STRING(2083), allowNull: true }, + recipe: { type: DataTypes.JSONB, allowNull: true }, + is_default: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, }, - -name: { - type: DataTypes.TEXT, - - - - }, - -category: { - type: DataTypes.ENUM, - - - - values: [ - -"policy", - - -"report", - - -"id", - - -"medical", - - -"consent", - - -"invoice", - - -"receipt", - - -"other" - - ], - - }, - -uploaded_at: { - type: DataTypes.DATE, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true, }, + organizationId: { 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 }, - campusId: { type: DataTypes.UUID, allowNull: true }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, }, { sequelize, - modelName: 'documents', + modelName: 'audio_files', timestamps: true, paranoid: true, freezeTableName: true, }, ); - return Documents; + return AudioFiles; } diff --git a/backend/src/db/models/campuses.ts b/backend/src/db/models/campuses.ts index 1b4b0a9..67ba0b7 100644 --- a/backend/src/db/models/campuses.ts +++ b/backend/src/db/models/campuses.ts @@ -15,12 +15,9 @@ import type { } from 'sequelize'; import type { AttendanceSessions } from './attendance_sessions'; import type { Classes } from './classes'; -import type { Documents } from './documents'; -import type { Invoices } from './invoices'; import type { Messages } from './messages'; import type { Organizations } from './organizations'; import type { Staff } from './staff'; -import type { Students } from './students'; import type { Timetables } from './timetables'; import type { Users } from './users'; @@ -52,8 +49,6 @@ export class Campuses extends Model< declare deletedAt: CreationOptional; - declare getStudents_campus: HasManyGetAssociationsMixin; - declare setStudents_campus: HasManySetAssociationsMixin; declare getStaff_campus: HasManyGetAssociationsMixin; declare setStaff_campus: HasManySetAssociationsMixin; declare getClasses_campus: HasManyGetAssociationsMixin; @@ -62,12 +57,8 @@ export class Campuses extends Model< declare setTimetables_campus: HasManySetAssociationsMixin; declare getAttendance_sessions_campus: HasManyGetAssociationsMixin; declare setAttendance_sessions_campus: HasManySetAssociationsMixin; - declare getInvoices_campus: HasManyGetAssociationsMixin; - declare setInvoices_campus: HasManySetAssociationsMixin; declare getMessages_campus: HasManyGetAssociationsMixin; declare setMessages_campus: HasManySetAssociationsMixin; - declare getDocuments_campus: HasManyGetAssociationsMixin; - declare setDocuments_campus: HasManySetAssociationsMixin; declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -76,12 +67,6 @@ export class Campuses extends Model< declare setUpdatedBy: BelongsToSetAssociationMixin; static associate(db: Db): void { - db.campuses.hasMany(db.students, { - as: 'students_campus', - foreignKey: { name: 'campusId' }, - constraints: false, - }); - db.campuses.hasMany(db.staff, { as: 'staff_campus', foreignKey: { name: 'campusId' }, @@ -106,24 +91,12 @@ export class Campuses extends Model< constraints: false, }); - db.campuses.hasMany(db.invoices, { - as: 'invoices_campus', - foreignKey: { name: 'campusId' }, - constraints: false, - }); - db.campuses.hasMany(db.messages, { as: 'messages_campus', foreignKey: { name: 'campusId' }, constraints: false, }); - db.campuses.hasMany(db.documents, { - as: 'documents_campus', - foreignKey: { name: 'campusId' }, - constraints: false, - }); - db.campuses.belongsTo(db.organizations, { as: 'organization', foreignKey: { name: 'organizationId' }, diff --git a/backend/src/db/models/class_enrollments.ts b/backend/src/db/models/class_enrollments.ts index 7aa59a0..f79647a 100644 --- a/backend/src/db/models/class_enrollments.ts +++ b/backend/src/db/models/class_enrollments.ts @@ -13,7 +13,6 @@ import type { } from 'sequelize'; import type { Classes } from './classes'; import type { Organizations } from './organizations'; -import type { Students } from './students'; import type { Users } from './users'; export class ClassEnrollments extends Model< @@ -39,8 +38,6 @@ export class ClassEnrollments extends Model< declare setOrganization: BelongsToSetAssociationMixin; declare getClass: BelongsToGetAssociationMixin; declare setClass: BelongsToSetAssociationMixin; - declare getStudent: BelongsToGetAssociationMixin; - declare setStudent: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -99,14 +96,6 @@ export class ClassEnrollments extends Model< constraints: false, }); - db.class_enrollments.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/communication_events.ts b/backend/src/db/models/communication_events.ts index 2c26d94..61e7b61 100644 --- a/backend/src/db/models/communication_events.ts +++ b/backend/src/db/models/communication_events.ts @@ -11,7 +11,7 @@ import { COMMUNICATION_EVENT_TYPE_VALUES, type CommunicationEventType, } from '@/shared/constants/communications'; -import type { ProductRoleValue } from '@/shared/constants/roles'; +import type { RoleName } from '@/shared/constants/roles'; import type { BelongsToGetAssociationMixin, BelongsToSetAssociationMixin, @@ -28,7 +28,7 @@ export class CommunicationEvents extends Model< declare title: string; declare event_date: string; declare event_type: CommunicationEventType; - declare roles: CreationOptional; + declare roles: CreationOptional; declare importHash: CreationOptional; declare createdAt: CreationOptional; declare updatedAt: CreationOptional; diff --git a/backend/src/db/models/fee_plans.ts b/backend/src/db/models/fee_plans.ts deleted file mode 100644 index f38eb8e..0000000 --- a/backend/src/db/models/fee_plans.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, -} from 'sequelize'; -import type { AcademicYears } from './academic_years'; -import type { Grades } from './grades'; -import type { Invoices } from './invoices'; -import type { Organizations } from './organizations'; -import type { Users } from './users'; - -export class FeePlans extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare name: string | null; - declare billing_cycle: string | null; - declare total_amount: string | null; - declare active: CreationOptional; - declare notes: string | null; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare academic_yearId: CreationOptional; - declare organizationId: CreationOptional; - declare gradeId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getInvoices_fee_plan: HasManyGetAssociationsMixin; - declare setInvoices_fee_plan: HasManySetAssociationsMixin; - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getAcademic_year: BelongsToGetAssociationMixin; - declare setAcademic_year: BelongsToSetAssociationMixin; - declare getGrade: BelongsToGetAssociationMixin; - declare setGrade: BelongsToSetAssociationMixin; - 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.fee_plans.hasMany(db.invoices, { - as: 'invoices_fee_plan', - foreignKey: { - name: 'fee_planId', - }, - constraints: false, - }); - - - - - - - - - -//end loop - - - - db.fee_plans.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.fee_plans.belongsTo(db.academic_years, { - as: 'academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - db.fee_plans.belongsTo(db.grades, { - as: 'grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - - - - - db.fee_plans.belongsTo(db.users, { - as: 'createdBy', - }); - - db.fee_plans.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof FeePlans { - FeePlans.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -billing_cycle: { - type: DataTypes.ENUM, - - - - values: [ - -"one_time", - - -"monthly", - - -"termly", - - -"annual" - - ], - - }, - -total_amount: { - type: DataTypes.DECIMAL, - - - - }, - -active: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - createdAt: { type: DataTypes.DATE }, - updatedAt: { type: DataTypes.DATE }, - deletedAt: { type: DataTypes.DATE }, - academic_yearId: { type: DataTypes.UUID, allowNull: true }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - gradeId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'fee_plans', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return FeePlans; -} diff --git a/backend/src/db/models/grades.ts b/backend/src/db/models/grades.ts index 55aee6b..0aa2d7a 100644 --- a/backend/src/db/models/grades.ts +++ b/backend/src/db/models/grades.ts @@ -14,7 +14,6 @@ import type { HasManySetAssociationsMixin, } from 'sequelize'; import type { Classes } from './classes'; -import type { FeePlans } from './fee_plans'; import type { Organizations } from './organizations'; import type { Users } from './users'; @@ -38,8 +37,6 @@ export class Grades extends Model< declare getClasses_grade: HasManyGetAssociationsMixin; declare setClasses_grade: HasManySetAssociationsMixin; - declare getFee_plans_grade: HasManyGetAssociationsMixin; - declare setFee_plans_grade: HasManySetAssociationsMixin; declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -79,14 +76,6 @@ export class Grades extends Model< - db.grades.hasMany(db.fee_plans, { - as: 'fee_plans_grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/guardians.ts b/backend/src/db/models/guardians.ts deleted file mode 100644 index 948d589..0000000 --- a/backend/src/db/models/guardians.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, -} from 'sequelize'; -import type { Organizations } from './organizations'; -import type { Students } from './students'; -import type { Users } from './users'; - -export class Guardians extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare full_name: string | null; - declare relationship: string | null; - declare phone: string | null; - declare email: string | null; - declare address: string | null; - declare primary_contact: CreationOptional; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare organizationId: CreationOptional; - declare studentId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getStudent: BelongsToGetAssociationMixin; - declare setStudent: BelongsToSetAssociationMixin; - 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.guardians.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.guardians.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.guardians.belongsTo(db.users, { - as: 'createdBy', - }); - - db.guardians.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof Guardians { - Guardians.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -full_name: { - type: DataTypes.TEXT, - - - - }, - -relationship: { - type: DataTypes.ENUM, - - - - values: [ - -"mother", - - -"father", - - -"guardian", - - -"other" - - ], - - }, - -phone: { - type: DataTypes.TEXT, - - - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -address: { - type: DataTypes.TEXT, - - - - }, - -primary_contact: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - createdAt: { type: DataTypes.DATE }, - updatedAt: { type: DataTypes.DATE }, - deletedAt: { type: DataTypes.DATE }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - studentId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'guardians', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return Guardians; -} diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index d0be5e3..e9c3a1b 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -3,12 +3,14 @@ import dbConfig from '@/db/db.config'; import { DEFAULT_DEV_DB_HOST, DEFAULT_DEV_DB_NAME, + DEFAULT_DEV_DB_PASS, DEFAULT_DEV_DB_USER, } from '@/shared/constants/app'; import academic_years from './academic_years'; import assessment_results from './assessment_results'; import assessments from './assessments'; +import audio_files from './audio_files'; import attendance_records from './attendance_records'; import attendance_sessions from './attendance_sessions'; import auth_refresh_tokens from './auth_refresh_tokens'; @@ -20,24 +22,20 @@ import class_subjects from './class_subjects'; import classes from './classes'; import communication_events from './communication_events'; import content_catalog from './content_catalog'; -import documents from './documents'; -import fee_plans from './fee_plans'; import file from './file'; import frame_entries from './frame_entries'; import grades from './grades'; -import guardians from './guardians'; -import invoices from './invoices'; import message_recipients from './message_recipients'; import messages from './messages'; import organizations from './organizations'; -import payments from './payments'; import permissions from './permissions'; import personality_quiz_results from './personality_quiz_results'; +import policy_acknowledgments from './policy_acknowledgments'; +import policy_documents from './policy_documents'; import roles from './roles'; import safety_quiz_results from './safety_quiz_results'; import staff from './staff'; import staff_attendance_records from './staff_attendance_records'; -import students from './students'; import subjects from './subjects'; import timetable_periods from './timetable_periods'; import timetables from './timetables'; @@ -46,6 +44,7 @@ import walkthrough_checkins from './walkthrough_checkins'; import users from './users'; const env = process.env.NODE_ENV || 'development'; +const isProductionLike = env === 'production' || env === 'dev_stage'; function selectConnection(): { database?: string; @@ -65,12 +64,35 @@ function selectConnection(): { return dbConfig.development; } +/** + * Validates that all required DB credentials are set in production-like environments. + * Dev defaults must never be used in production. + */ +function validateProductionDbConfig(connection: ReturnType): void { + if (!isProductionLike) { + return; + } + + const missing: string[] = []; + if (!connection.database) missing.push('DB_NAME'); + if (!connection.username) missing.push('DB_USER'); + if (!connection.password) missing.push('DB_PASS'); + if (!connection.host) missing.push('DB_HOST'); + + if (missing.length > 0) { + throw new Error( + `Missing required database credentials in ${env}: ${missing.join(', ')} (no dev defaults allowed)`, + ); + } +} + const connection = selectConnection(); +validateProductionDbConfig(connection); const sequelize = new Sequelize( connection.database ?? DEFAULT_DEV_DB_NAME, connection.username ?? DEFAULT_DEV_DB_USER, - connection.password ?? '', + connection.password ?? DEFAULT_DEV_DB_PASS, { dialect: 'postgres', host: connection.host ?? DEFAULT_DEV_DB_HOST, @@ -83,6 +105,7 @@ const models = { academic_years: academic_years(sequelize), assessment_results: assessment_results(sequelize), assessments: assessments(sequelize), + audio_files: audio_files(sequelize), attendance_records: attendance_records(sequelize), attendance_sessions: attendance_sessions(sequelize), auth_refresh_tokens: auth_refresh_tokens(sequelize), @@ -94,24 +117,20 @@ const models = { classes: classes(sequelize), communication_events: communication_events(sequelize), content_catalog: content_catalog(sequelize), - documents: documents(sequelize), - fee_plans: fee_plans(sequelize), file: file(sequelize), frame_entries: frame_entries(sequelize), grades: grades(sequelize), - guardians: guardians(sequelize), - invoices: invoices(sequelize), message_recipients: message_recipients(sequelize), messages: messages(sequelize), organizations: organizations(sequelize), - payments: payments(sequelize), permissions: permissions(sequelize), personality_quiz_results: personality_quiz_results(sequelize), + policy_acknowledgments: policy_acknowledgments(sequelize), + policy_documents: policy_documents(sequelize), roles: roles(sequelize), safety_quiz_results: safety_quiz_results(sequelize), staff: staff(sequelize), staff_attendance_records: staff_attendance_records(sequelize), - students: students(sequelize), subjects: subjects(sequelize), timetable_periods: timetable_periods(sequelize), timetables: timetables(sequelize), diff --git a/backend/src/db/models/invoices.ts b/backend/src/db/models/invoices.ts deleted file mode 100644 index e1337e7..0000000 --- a/backend/src/db/models/invoices.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, -} from 'sequelize'; -import type { Campuses } from './campuses'; -import type { FeePlans } from './fee_plans'; -import type { File } from './file'; -import type { Organizations } from './organizations'; -import type { Payments } from './payments'; -import type { Students } from './students'; -import type { Users } from './users'; - -export class Invoices extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare invoice_number: string | null; - declare issue_date: Date | null; - declare due_date: Date | null; - declare subtotal: string | null; - declare discount_amount: string | null; - declare tax_amount: string | null; - declare total_amount: string | null; - declare balance_due: string | null; - declare status: string | null; - declare notes: string | null; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare campusId: CreationOptional; - declare fee_planId: CreationOptional; - declare organizationId: CreationOptional; - declare studentId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getPayments_invoice: HasManyGetAssociationsMixin; - declare setPayments_invoice: HasManySetAssociationsMixin; - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getCampus: BelongsToGetAssociationMixin; - declare setCampus: BelongsToSetAssociationMixin; - declare getStudent: BelongsToGetAssociationMixin; - declare setStudent: BelongsToSetAssociationMixin; - declare getFee_plan: BelongsToGetAssociationMixin; - declare setFee_plan: BelongsToSetAssociationMixin; - declare getAttachments: HasManyGetAssociationsMixin; - declare setAttachments: 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.invoices.hasMany(db.payments, { - as: 'payments_invoice', - foreignKey: { - name: 'invoiceId', - }, - constraints: false, - }); - - - - - - - - -//end loop - - - - db.invoices.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.fee_plans, { - as: 'fee_plan', - foreignKey: { - name: 'fee_planId', - }, - constraints: false, - }); - - - - db.invoices.hasMany(db.file, { - as: 'attachments', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.invoices.getTableName(), - belongsToColumn: 'attachments', - }, - }); - - - db.invoices.belongsTo(db.users, { - as: 'createdBy', - }); - - db.invoices.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof Invoices { - Invoices.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -invoice_number: { - type: DataTypes.TEXT, - - - - }, - -issue_date: { - type: DataTypes.DATE, - - - - }, - -due_date: { - type: DataTypes.DATE, - - - - }, - -subtotal: { - type: DataTypes.DECIMAL, - - - - }, - -discount_amount: { - type: DataTypes.DECIMAL, - - - - }, - -tax_amount: { - type: DataTypes.DECIMAL, - - - - }, - -total_amount: { - type: DataTypes.DECIMAL, - - - - }, - -balance_due: { - type: DataTypes.DECIMAL, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"draft", - - -"issued", - - -"partially_paid", - - -"paid", - - -"overdue", - - -"void" - - ], - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - 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 }, - fee_planId: { type: DataTypes.UUID, allowNull: true }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - studentId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'invoices', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return Invoices; -} diff --git a/backend/src/db/models/organizations.ts b/backend/src/db/models/organizations.ts index d8f3984..3abb837 100644 --- a/backend/src/db/models/organizations.ts +++ b/backend/src/db/models/organizations.ts @@ -22,16 +22,10 @@ import type { Campuses } from './campuses'; import type { ClassEnrollments } from './class_enrollments'; import type { ClassSubjects } from './class_subjects'; import type { Classes } from './classes'; -import type { Documents } from './documents'; -import type { FeePlans } from './fee_plans'; import type { Grades } from './grades'; -import type { Guardians } from './guardians'; -import type { Invoices } from './invoices'; import type { MessageRecipients } from './message_recipients'; import type { Messages } from './messages'; -import type { Payments } from './payments'; import type { Staff } from './staff'; -import type { Students } from './students'; import type { Subjects } from './subjects'; import type { TimetablePeriods } from './timetable_periods'; import type { Timetables } from './timetables'; @@ -61,10 +55,6 @@ export class Organizations extends Model< declare setGrades_organization: HasManySetAssociationsMixin; declare getSubjects_organization: HasManyGetAssociationsMixin; declare setSubjects_organization: HasManySetAssociationsMixin; - declare getStudents_organization: HasManyGetAssociationsMixin; - declare setStudents_organization: HasManySetAssociationsMixin; - declare getGuardians_organization: HasManyGetAssociationsMixin; - declare setGuardians_organization: HasManySetAssociationsMixin; declare getStaff_organization: HasManyGetAssociationsMixin; declare setStaff_organization: HasManySetAssociationsMixin; declare getClasses_organization: HasManyGetAssociationsMixin; @@ -81,12 +71,6 @@ export class Organizations extends Model< declare setAttendance_sessions_organization: HasManySetAssociationsMixin; declare getAttendance_records_organization: HasManyGetAssociationsMixin; declare setAttendance_records_organization: HasManySetAssociationsMixin; - declare getFee_plans_organization: HasManyGetAssociationsMixin; - declare setFee_plans_organization: HasManySetAssociationsMixin; - declare getInvoices_organization: HasManyGetAssociationsMixin; - declare setInvoices_organization: HasManySetAssociationsMixin; - declare getPayments_organization: HasManyGetAssociationsMixin; - declare setPayments_organization: HasManySetAssociationsMixin; declare getAssessments_organization: HasManyGetAssociationsMixin; declare setAssessments_organization: HasManySetAssociationsMixin; declare getAssessment_results_organization: HasManyGetAssociationsMixin; @@ -95,8 +79,6 @@ export class Organizations extends Model< declare setMessages_organization: HasManySetAssociationsMixin; declare getMessage_recipients_organization: HasManyGetAssociationsMixin; declare setMessage_recipients_organization: HasManySetAssociationsMixin; - declare getDocuments_organization: HasManyGetAssociationsMixin; - declare setDocuments_organization: HasManySetAssociationsMixin; declare getCreatedBy: BelongsToGetAssociationMixin; declare setCreatedBy: BelongsToSetAssociationMixin; declare getUpdatedBy: BelongsToGetAssociationMixin; @@ -156,22 +138,6 @@ export class Organizations extends Model< }); - db.organizations.hasMany(db.students, { - as: 'students_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.guardians, { - as: 'guardians_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); db.organizations.hasMany(db.staff, { @@ -246,32 +212,8 @@ export class Organizations extends Model< }); - db.organizations.hasMany(db.fee_plans, { - as: 'fee_plans_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - db.organizations.hasMany(db.invoices, { - as: 'invoices_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.payments, { - as: 'payments_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - db.organizations.hasMany(db.assessments, { as: 'assessments_organization', @@ -309,14 +251,6 @@ export class Organizations extends Model< }); - db.organizations.hasMany(db.documents, { - as: 'documents_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - //end loop diff --git a/backend/src/db/models/payments.ts b/backend/src/db/models/payments.ts deleted file mode 100644 index ef7114d..0000000 --- a/backend/src/db/models/payments.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, -} from 'sequelize'; -import type { File } from './file'; -import type { Invoices } from './invoices'; -import type { Organizations } from './organizations'; -import type { Staff } from './staff'; -import type { Users } from './users'; - -export class Payments extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare receipt_number: string | null; - declare paid_at: Date | null; - declare amount: string | null; - declare method: string | null; - declare reference_code: string | null; - declare notes: string | null; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare invoiceId: CreationOptional; - declare organizationId: CreationOptional; - declare received_byId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getInvoice: BelongsToGetAssociationMixin; - declare setInvoice: BelongsToSetAssociationMixin; - declare getReceived_by: BelongsToGetAssociationMixin; - declare setReceived_by: BelongsToSetAssociationMixin; - declare getProof: HasManyGetAssociationsMixin; - declare setProof: 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.payments.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.payments.belongsTo(db.invoices, { - as: 'invoice', - foreignKey: { - name: 'invoiceId', - }, - constraints: false, - }); - - db.payments.belongsTo(db.staff, { - as: 'received_by', - foreignKey: { - name: 'received_byId', - }, - constraints: false, - }); - - - - db.payments.hasMany(db.file, { - as: 'proof', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.payments.getTableName(), - belongsToColumn: 'proof', - }, - }); - - - db.payments.belongsTo(db.users, { - as: 'createdBy', - }); - - db.payments.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof Payments { - Payments.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -receipt_number: { - type: DataTypes.TEXT, - - - - }, - -paid_at: { - type: DataTypes.DATE, - - - - }, - -amount: { - type: DataTypes.DECIMAL, - - - - }, - -method: { - type: DataTypes.ENUM, - - - - values: [ - -"cash", - - -"bank_transfer", - - -"card", - - -"mobile_money", - - -"cheque", - - -"other" - - ], - - }, - -reference_code: { - type: DataTypes.TEXT, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - createdAt: { type: DataTypes.DATE }, - updatedAt: { type: DataTypes.DATE }, - deletedAt: { type: DataTypes.DATE }, - invoiceId: { type: DataTypes.UUID, allowNull: true }, - organizationId: { type: DataTypes.UUID, allowNull: true }, - received_byId: { type: DataTypes.UUID, allowNull: true }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'payments', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return Payments; -} diff --git a/backend/src/db/models/policy_acknowledgments.ts b/backend/src/db/models/policy_acknowledgments.ts new file mode 100644 index 0000000..5ae308d --- /dev/null +++ b/backend/src/db/models/policy_acknowledgments.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, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Organizations } from './organizations'; +import type { PolicyDocuments } from './policy_documents'; +import type { Users } from './users'; + +export class PolicyAcknowledgments extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare policyDocumentId: string; + declare version: number; + declare userId: string; + declare acknowledgedAt: Date; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + + declare getPolicyDocument: BelongsToGetAssociationMixin; + declare setPolicyDocument: BelongsToSetAssociationMixin; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.policy_acknowledgments.belongsTo(db.policy_documents, { + as: 'policyDocument', + foreignKey: { name: 'policyDocumentId' }, + constraints: false, + }); + db.policy_acknowledgments.belongsTo(db.users, { + as: 'user', + foreignKey: { name: 'userId' }, + constraints: false, + }); + db.policy_acknowledgments.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { name: 'organizationId' }, + constraints: false, + }); + } +} + +export default function (sequelize: Sequelize): typeof PolicyAcknowledgments { + PolicyAcknowledgments.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + policyDocumentId: { type: DataTypes.UUID, allowNull: false }, + version: { type: DataTypes.INTEGER, allowNull: false }, + userId: { type: DataTypes.UUID, allowNull: false }, + acknowledgedAt: { type: DataTypes.DATE, allowNull: false }, + organizationId: { 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 }, + }, + { + sequelize, + modelName: 'policy_acknowledgments', + timestamps: true, + freezeTableName: true, + }, + ); + + return PolicyAcknowledgments; +} diff --git a/backend/src/db/models/policy_documents.ts b/backend/src/db/models/policy_documents.ts new file mode 100644 index 0000000..cb124d4 --- /dev/null +++ b/backend/src/db/models/policy_documents.ts @@ -0,0 +1,115 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import { + POLICY_DOCUMENT_CATEGORY_VALUES, + type PolicyDocumentCategory, +} from '@/shared/constants/policy-documents'; +import type { Organizations } from './organizations'; +import type { Campuses } from './campuses'; +import type { Users } from './users'; + +export class PolicyDocuments extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare title: string; + declare body: CreationOptional; + declare category: PolicyDocumentCategory; + declare tag: CreationOptional; + declare author: CreationOptional; + /** Author-filled structured content (safety protocols): ordered steps. */ + declare steps: CreationOptional; + /** Author-filled structured content (safety protocols): autism considerations. */ + declare autism_considerations: CreationOptional; + declare version: CreationOptional; + declare active: CreationOptional; + declare importHash: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.policy_documents.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { name: 'organizationId' }, + constraints: false, + }); + db.policy_documents.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + db.policy_documents.belongsTo(db.users, { as: 'createdBy' }); + db.policy_documents.belongsTo(db.users, { as: 'updatedBy' }); + } +} + +export default function (sequelize: Sequelize): typeof PolicyDocuments { + PolicyDocuments.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { type: DataTypes.TEXT, allowNull: false }, + body: { type: DataTypes.TEXT, allowNull: true }, + category: { + type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES), + allowNull: false, + }, + tag: { type: DataTypes.STRING(255), allowNull: true }, + author: { type: DataTypes.STRING(255), allowNull: true }, + steps: { type: DataTypes.JSONB, allowNull: true }, + autism_considerations: { type: DataTypes.JSONB, allowNull: true }, + version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 }, + active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + organizationId: { 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: 'policy_documents', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return PolicyDocuments; +} diff --git a/backend/src/db/models/roles.ts b/backend/src/db/models/roles.ts index 72ca52a..d81e436 100644 --- a/backend/src/db/models/roles.ts +++ b/backend/src/db/models/roles.ts @@ -8,6 +8,7 @@ import { type Sequelize, } from 'sequelize'; import type { Db } from '@/db/types'; +import { ROLE_SCOPE_VALUES, type RoleScope } from '@/shared/constants/roles'; import type { BelongsToGetAssociationMixin, BelongsToManyGetAssociationsMixin, @@ -25,6 +26,8 @@ export class Roles extends Model< > { declare id: CreationOptional; declare name: string | null; + /** Authorization scope (Workstream 3 §3.1). Required on every role. */ + declare scope: CreationOptional; declare globalAccess: CreationOptional; declare importHash: CreationOptional; declare createdAt: CreationOptional; @@ -139,6 +142,11 @@ name: { }, + scope: { + type: DataTypes.ENUM(...ROLE_SCOPE_VALUES), + allowNull: false, + }, + globalAccess: { type: DataTypes.BOOLEAN, diff --git a/backend/src/db/models/staff.ts b/backend/src/db/models/staff.ts index 3c41d43..9bb3f19 100644 --- a/backend/src/db/models/staff.ts +++ b/backend/src/db/models/staff.ts @@ -20,7 +20,6 @@ import type { ClassSubjects } from './class_subjects'; import type { Classes } from './classes'; import type { File } from './file'; import type { Organizations } from './organizations'; -import type { Payments } from './payments'; import type { Users } from './users'; export class Staff extends Model< @@ -50,8 +49,6 @@ export class Staff extends Model< declare setClass_subjects_teacher: HasManySetAssociationsMixin; declare getAttendance_sessions_taken_by: HasManyGetAssociationsMixin; declare setAttendance_sessions_taken_by: HasManySetAssociationsMixin; - declare getPayments_received_by: HasManyGetAssociationsMixin; - declare setPayments_received_by: HasManySetAssociationsMixin; declare getOrganization: BelongsToGetAssociationMixin; declare setOrganization: BelongsToSetAssociationMixin; declare getCampus: BelongsToGetAssociationMixin; @@ -117,14 +114,6 @@ export class Staff extends Model< - db.staff.hasMany(db.payments, { - as: 'payments_received_by', - foreignKey: { - name: 'received_byId', - }, - constraints: false, - }); - diff --git a/backend/src/db/models/students.ts b/backend/src/db/models/students.ts deleted file mode 100644 index 3420486..0000000 --- a/backend/src/db/models/students.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { - DataTypes, - Model, - type CreationOptional, - type InferAttributes, - type InferCreationAttributes, - type Sequelize, -} from 'sequelize'; -import type { Db } from '@/db/types'; -import type { - BelongsToGetAssociationMixin, - BelongsToSetAssociationMixin, - HasManyGetAssociationsMixin, - HasManySetAssociationsMixin, -} from 'sequelize'; -import type { AssessmentResults } from './assessment_results'; -import type { AttendanceRecords } from './attendance_records'; -import type { Campuses } from './campuses'; -import type { ClassEnrollments } from './class_enrollments'; -import type { File } from './file'; -import type { Guardians } from './guardians'; -import type { Invoices } from './invoices'; -import type { Organizations } from './organizations'; -import type { Users } from './users'; - -export class Students extends Model< - InferAttributes, - InferCreationAttributes -> { - declare id: CreationOptional; - declare student_number: string | null; - declare first_name: string | null; - declare last_name: string | null; - declare gender: string | null; - declare date_of_birth: Date | null; - declare enrollment_date: Date | null; - declare status: string | null; - declare email: string | null; - declare phone: string | null; - declare address: string | null; - declare importHash: CreationOptional; - declare createdAt: CreationOptional; - declare updatedAt: CreationOptional; - declare deletedAt: CreationOptional; - declare campusId: CreationOptional; - declare organizationId: CreationOptional; - declare createdById: CreationOptional; - declare updatedById: CreationOptional; - - - declare getGuardians_student: HasManyGetAssociationsMixin; - declare setGuardians_student: HasManySetAssociationsMixin; - declare getClass_enrollments_student: HasManyGetAssociationsMixin; - declare setClass_enrollments_student: HasManySetAssociationsMixin; - declare getAttendance_records_student: HasManyGetAssociationsMixin; - declare setAttendance_records_student: HasManySetAssociationsMixin; - declare getInvoices_student: HasManyGetAssociationsMixin; - declare setInvoices_student: HasManySetAssociationsMixin; - declare getAssessment_results_student: HasManyGetAssociationsMixin; - declare setAssessment_results_student: HasManySetAssociationsMixin; - declare getOrganization: BelongsToGetAssociationMixin; - declare setOrganization: BelongsToSetAssociationMixin; - declare getCampus: BelongsToGetAssociationMixin; - declare setCampus: 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.students.hasMany(db.guardians, { - as: 'guardians_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.students.hasMany(db.class_enrollments, { - as: 'class_enrollments_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - - - db.students.hasMany(db.attendance_records, { - as: 'attendance_records_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - db.students.hasMany(db.invoices, { - as: 'invoices_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.students.hasMany(db.assessment_results, { - as: 'assessment_results_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - - -//end loop - - - - db.students.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.students.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.students.hasMany(db.file, { - as: 'photo', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.students.getTableName(), - belongsToColumn: 'photo', - }, - }); - - - db.students.belongsTo(db.users, { - as: 'createdBy', - }); - - db.students.belongsTo(db.users, { - as: 'updatedBy', - }); - } -} - -export default function (sequelize: Sequelize): typeof Students { - Students.init( - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -student_number: { - type: DataTypes.TEXT, - - - - }, - -first_name: { - type: DataTypes.TEXT, - - - - }, - -last_name: { - type: DataTypes.TEXT, - - - - }, - -gender: { - type: DataTypes.ENUM, - - - - values: [ - -"male", - - -"female", - - -"other", - - -"prefer_not_to_say" - - ], - - }, - -date_of_birth: { - type: DataTypes.DATE, - - - - }, - -enrollment_date: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"prospect", - - -"enrolled", - - -"inactive", - - -"graduated", - - -"transferred" - - ], - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -phone: { - type: DataTypes.TEXT, - - - - }, - -address: { - type: DataTypes.TEXT, - - - - }, - - 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 }, - createdById: { type: DataTypes.UUID, allowNull: true }, - updatedById: { type: DataTypes.UUID, allowNull: true }, - }, - { - sequelize, - modelName: 'students', - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return Students; -} diff --git a/backend/src/db/models/users.ts b/backend/src/db/models/users.ts index 49c598c..63eb5da 100644 --- a/backend/src/db/models/users.ts +++ b/backend/src/db/models/users.ts @@ -10,6 +10,10 @@ import { type Sequelize, } from 'sequelize'; import config from '@/shared/config'; +import { + USER_NAME_PREFIX_VALUES, + type UserNamePrefix, +} from '@/shared/constants/users'; import type { Db } from '@/db/types'; import type { BelongsToGetAssociationMixin, @@ -19,6 +23,7 @@ import type { HasManyGetAssociationsMixin, HasManySetAssociationsMixin, } from 'sequelize'; +import type { Campuses } from './campuses'; import type { File } from './file'; import type { Messages } from './messages'; import type { Organizations } from './organizations'; @@ -33,6 +38,7 @@ export class Users extends Model< InferCreationAttributes > { declare id: CreationOptional; + declare name_prefix: CreationOptional; declare firstName: CreationOptional; declare lastName: CreationOptional; declare phoneNumber: CreationOptional; @@ -47,6 +53,8 @@ export class Users extends Model< declare provider: CreationOptional; declare importHash: CreationOptional; declare organizationId: CreationOptional; + /** Campus scope for campus-bound roles (Workstream 3 §3.1). Nullable. */ + declare campusId: CreationOptional; declare createdById: CreationOptional; declare updatedById: CreationOptional; declare createdAt: CreationOptional; @@ -56,6 +64,7 @@ export class Users extends Model< // Eager-loaded associations (populated by `include`, not stored attributes). declare app_role?: NonAttribute; declare organizations?: NonAttribute; + declare campus?: NonAttribute; declare staff_user?: NonAttribute; declare custom_permissions?: NonAttribute; @@ -71,6 +80,8 @@ export class Users extends Model< declare setApp_role: BelongsToSetAssociationMixin; declare getOrganizations: BelongsToGetAssociationMixin; declare setOrganizations: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; declare getAvatar: HasManyGetAssociationsMixin; declare setAvatar: HasManySetAssociationsMixin; declare getCreatedBy: BelongsToGetAssociationMixin; @@ -129,6 +140,14 @@ export class Users extends Model< constraints: false, }); + db.users.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + db.users.hasMany(db.file, { as: 'avatar', foreignKey: 'belongsToId', @@ -167,6 +186,10 @@ export default function (sequelize: Sequelize): typeof Users { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, + name_prefix: { + type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES), + allowNull: true, + }, firstName: { type: DataTypes.TEXT, }, @@ -214,6 +237,7 @@ export default function (sequelize: Sequelize): typeof Users { unique: true, }, organizationId: { 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 }, diff --git a/backend/src/db/seeders/20200430130759-admin-user.ts b/backend/src/db/seeders/20200430130759-admin-user.ts index 4c8b16e..2f6fa6d 100644 --- a/backend/src/db/seeders/20200430130759-admin-user.ts +++ b/backend/src/db/seeders/20200430130759-admin-user.ts @@ -1,85 +1,64 @@ import bcrypt from 'bcrypt'; -import { Op, type CreationAttributes, type QueryInterface } from 'sequelize'; +import { + Op, + QueryTypes, + type CreationAttributes, + type QueryInterface, +} from 'sequelize'; import config from '@/shared/config'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures'; import type { Users } from '@/db/models/users'; -/** Seed-only env vars are required: the admin account is needed to log in. */ -function requiredSeedEnv(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Seeding requires environment variable: ${name}`); - } - return value; +/** Documented seed credentials - see CLAUDE.md for reference. */ +const SEED_DEFAULTS: Record = { + SEED_ADMIN_PASSWORD: 'flatlogicAdmin123!', + SEED_USER_PASSWORD: 'flatlogicUser123!', + SEED_ADMIN_EMAIL: 'admin@flatlogic.com', +}; + +function seedEnv(name: string): string { + return process.env[name] || SEED_DEFAULTS[name] || ''; } -const ids = [ - '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af', - 'af5a87be-8f9c-4630-902a-37a60b7005ba', - '5bc531ab-611f-41f3-9373-b7cc5d09c93d', - 'ab4cf9bf-4eef-4107-b73d-9d0274cf69bc', -]; +const ids = SEED_ALL_USERS.map((user) => user.id); +/** + * Seeds the RBAC fixture users — exactly one per stored role (Workstream 4). + * `admin@flatlogic.com` (`SEED_ADMIN_EMAIL`) is the super admin. Roles, org, and + * campus links are applied by later seeders. Idempotent: skips ids already present. + */ export default { up: async (queryInterface: QueryInterface) => { const adminHash = bcrypt.hashSync( - requiredSeedEnv('SEED_ADMIN_PASSWORD'), + seedEnv('SEED_ADMIN_PASSWORD'), config.bcrypt.saltRounds, ); const userHash = bcrypt.hashSync( - requiredSeedEnv('SEED_USER_PASSWORD'), + seedEnv('SEED_USER_PASSWORD'), config.bcrypt.saltRounds, ); - const adminEmail = requiredSeedEnv('SEED_ADMIN_EMAIL'); + const adminEmail = seedEnv('SEED_ADMIN_EMAIL'); const createdAt = new Date(); const updatedAt = new Date(); - const rows: CreationAttributes[] = [ - { - id: ids[0], - firstName: 'Admin', - email: adminEmail, - emailVerified: true, - provider: config.providers.LOCAL, - password: adminHash, - createdAt, - updatedAt, - }, - { - id: ids[1], - firstName: 'John', - email: 'john@doe.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: userHash, - createdAt, - updatedAt, - }, - { - id: ids[2], - firstName: 'Client', - email: 'client@hello.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: userHash, - createdAt, - updatedAt, - }, - { - id: ids[3], - firstName: 'Super Admin', - email: 'super_admin@flatlogic.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: adminHash, - createdAt, - updatedAt, - }, - ]; + const rows: CreationAttributes[] = SEED_ALL_USERS.map((user) => ({ + id: user.id, + name_prefix: user.namePrefix ?? null, + firstName: user.firstName, + lastName: user.lastName, + // The super admin uses the configured SEED_ADMIN_EMAIL for login. + email: user.role === ROLE_NAMES.SUPER_ADMIN ? adminEmail : user.email, + emailVerified: true, + provider: config.providers.LOCAL, + password: user.admin ? adminHash : userHash, + createdAt, + updatedAt, + })); - // Check which users already exist to make seeder idempotent const existing = await queryInterface.sequelize.query<{ id: string }>( `SELECT id FROM users WHERE id IN (:ids)`, - { replacements: { ids }, type: 'SELECT' }, + { replacements: { ids }, type: QueryTypes.SELECT }, ); const existingIds = new Set(existing.map((row) => row.id)); const toInsert = rows.filter((row) => !existingIds.has(row.id as string)); diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 1db4e8f..8d182a2 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -1,2715 +1,217 @@ - import { v4 as uuid } from 'uuid'; import type { CreationAttributes, QueryInterface } from 'sequelize'; +import { + ROLE_DEFINITIONS, + ROLE_NAMES, + type RoleName, +} from '@/shared/constants/roles'; +import { SEED_ALL_USERS } from '@/shared/constants/seed-fixtures'; +import { + MODULE_READ_ALL_STAFF, + MODULE_READ_INSTRUCTIONAL, + MODULE_READ_PARENT_COMM, + MODULE_READ_EXTERNAL, + MODULE_ACTIONS, + MODULE_PERMISSIONS, +} from '@/shared/constants/product-permissions'; 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, + * 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. + * + * 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. + * - `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). + */ + +const PERMISSION_ENTITIES = [ + 'users', 'roles', 'permissions', 'organizations', 'campuses', + 'academic_years', 'grades', 'subjects', 'staff', + 'classes', 'class_enrollments', 'class_subjects', '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']; + +/** Roles granted every permission (full CRUD within their tenant/campus scope). */ +const FULL_ACCESS_ROLES: readonly RoleName[] = [ + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, +]; + +/** Roles granted read-only access to tenant resources. */ +const READ_ONLY_ROLES: readonly RoleName[] = [ + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, +]; + +/** External (non-staff) roles. */ +const EXTERNAL_ROLES: readonly RoleName[] = [ + ROLE_NAMES.STUDENT, + ROLE_NAMES.GUARDIAN, +]; + +// Product module/page permissions (§3.2) come from the shared single source +// `shared/constants/product-permissions.ts`, which the feature routes also use +// to enforce them — so granted names and checked names never drift. + +/** + * Per-role product-feature grants for the non-global, non-full-access roles. + * `office_manager` excludes instructional tools and parent comms but 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. + */ +const MODULE_PERMISSIONS_BY_ROLE: Partial> = { + [ROLE_NAMES.OFFICE_MANAGER]: [ + ...MODULE_READ_ALL_STAFF, + ...MODULE_READ_EXTERNAL, + ...MODULE_ACTIONS, + 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', + ], + [ROLE_NAMES.TEACHER]: [ + ...MODULE_READ_ALL_STAFF, + ...MODULE_READ_INSTRUCTIONAL, + ...MODULE_READ_PARENT_COMM, + ...MODULE_READ_EXTERNAL, + 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', + 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', + ], + [ROLE_NAMES.SUPPORT_STAFF]: [ + ...MODULE_READ_ALL_STAFF, + ...MODULE_READ_INSTRUCTIONAL, + ...MODULE_READ_EXTERNAL, + 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', + 'READ_AUDIO_FILES', + ], + [ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL], + [ROLE_NAMES.GUARDIAN]: [...MODULE_READ_EXTERNAL], +}; + export default { async up(queryInterface: QueryInterface) { const createdAt = new Date(); const updatedAt = new Date(); - const idMap = new Map(); + const roleId = new Map(); + const permId = new Map(); - function getId(key: string): string { - const existing = idMap.get(key); - if (existing !== undefined) { - return existing; + // 1. Roles. + const roleRows: CreationAttributes[] = ROLE_DEFINITIONS.map( + (role) => { + const id = uuid(); + roleId.set(role.name, id); + return { + id, + name: role.name, + scope: role.scope, + globalAccess: role.globalAccess, + createdAt, + updatedAt, + }; + }, + ); + 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 id = uuid(); - idMap.set(key, id); - return id; } - await queryInterface.bulkInsert("roles", [ - - { id: getId("SuperAdmin"), name: "Super Administrator", createdAt, updatedAt }, - - - { id: getId("Administrator"), name: "Administrator", createdAt, updatedAt }, - - - - { id: getId("PlatformOwner"), name: "Platform Owner", createdAt, updatedAt }, - - { id: getId("TenantDirector"), name: "Tenant Director", createdAt, updatedAt }, - - { id: getId("CampusManager"), name: "Campus Manager", createdAt, updatedAt }, - - { id: getId("AcademicCoordinator"), name: "Academic Coordinator", createdAt, updatedAt }, - - { id: getId("FinanceOfficer"), name: "Finance Officer", createdAt, updatedAt }, - - - - { id: getId("Public"), name: "Public", createdAt, updatedAt }, - ] satisfies CreationAttributes[]); - - function createPermissions( - name: string, - ): CreationAttributes[] { - return [ - { id: getId(`CREATE_${name.toUpperCase()}`), createdAt, updatedAt, name: `CREATE_${name.toUpperCase()}` }, - { id: getId(`READ_${name.toUpperCase()}`), createdAt, updatedAt, name: `READ_${name.toUpperCase()}` }, - { id: getId(`UPDATE_${name.toUpperCase()}`), createdAt, updatedAt, name: `UPDATE_${name.toUpperCase()}` }, - { id: getId(`DELETE_${name.toUpperCase()}`), createdAt, updatedAt, name: `DELETE_${name.toUpperCase()}` } - ]; - } - - const entities = [ - "users","roles","permissions","organizations","campuses","academic_years","grades","subjects","students","guardians","staff","classes","class_enrollments","class_subjects","timetables","timetable_periods","attendance_sessions","attendance_records","fee_plans","invoices","payments","assessments","assessment_results","messages","message_recipients","documents", + const allPermissionNames = [ + ...entityPermissionNames, + ...EXTRA_PERMISSIONS, + ...MODULE_PERMISSIONS, ]; -await queryInterface.bulkInsert("permissions", entities.flatMap(createPermissions)); -await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), createdAt, updatedAt, name: `READ_API_DOCS` }]); -await queryInterface.bulkInsert("permissions", [{ id: getId(`CREATE_SEARCH`), createdAt, updatedAt, name: `CREATE_SEARCH`}]); -await queryInterface.bulkUpdate('roles', { globalAccess: true }, { id: getId('SuperAdmin') }); -await queryInterface.bulkUpdate('roles', { globalAccess: true }, { id: getId('Administrator') }); + const permissionRows: CreationAttributes[] = + allPermissionNames.map((name) => { + const id = uuid(); + permId.set(name, id); + return { id, name, createdAt, updatedAt }; + }); + await queryInterface.bulkInsert('permissions', permissionRows); -// The "rolesPermissionsPermissions" join table is created by `sequelize.sync` -// from the roles<->permissions M:N association, so the seeder only inserts rows. -await queryInterface.bulkInsert("rolesPermissionsPermissions", [ - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_USERS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_USERS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_USERS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_USERS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_USERS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_USERS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_USERS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_CAMPUSES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_CAMPUSES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_CAMPUSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_CAMPUSES') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_CAMPUSES') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_CAMPUSES') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_ACADEMIC_YEARS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_ACADEMIC_YEARS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_ACADEMIC_YEARS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_GRADES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_GRADES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_GRADES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_GRADES') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_GRADES') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_SUBJECTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_SUBJECTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_SUBJECTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_SUBJECTS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_STUDENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_STUDENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_STUDENTS') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_STUDENTS') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_STUDENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_STUDENTS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_GUARDIANS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_GUARDIANS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_GUARDIANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_GUARDIANS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_GUARDIANS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_GUARDIANS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_STAFF') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_STAFF') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_STAFF') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_STAFF') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_STAFF') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_STAFF') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_CLASSES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_CLASSES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_CLASSES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_CLASSES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_CLASSES') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_CLASSES') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_CLASS_ENROLLMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_CLASS_ENROLLMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_CLASS_SUBJECTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_CLASS_SUBJECTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_CLASS_SUBJECTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_TIMETABLES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_TIMETABLES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_TIMETABLES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_TIMETABLES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_TIMETABLES') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_TIMETABLE_PERIODS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_TIMETABLE_PERIODS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_ATTENDANCE_SESSIONS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_ATTENDANCE_SESSIONS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_ATTENDANCE_RECORDS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_ATTENDANCE_RECORDS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_FEE_PLANS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_FEE_PLANS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_FEE_PLANS') }, - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('CREATE_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_FEE_PLANS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_FEE_PLANS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_INVOICES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_INVOICES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_INVOICES') }, - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('CREATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_INVOICES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('DELETE_INVOICES') }, - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_PAYMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_PAYMENTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_PAYMENTS') }, - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('CREATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_PAYMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('DELETE_PAYMENTS') }, - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_ASSESSMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_ASSESSMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_ASSESSMENTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_ASSESSMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_ASSESSMENTS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_ASSESSMENT_RESULTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_ASSESSMENT_RESULTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_MESSAGES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_MESSAGES') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_MESSAGES') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_MESSAGES') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_MESSAGES') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_MESSAGES') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_MESSAGE_RECIPIENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_MESSAGE_RECIPIENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - - - - - - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('READ_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('UPDATE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('DELETE_DOCUMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('READ_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('UPDATE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('DELETE_DOCUMENTS') }, - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('READ_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('UPDATE_DOCUMENTS') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('READ_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('UPDATE_DOCUMENTS') }, - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('READ_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('UPDATE_DOCUMENTS') }, - - - - - - - - - - - - - - - - { createdAt, updatedAt, roles_permissionsId: getId("PlatformOwner"), permissionId: getId('CREATE_SEARCH') }, - - { createdAt, updatedAt, roles_permissionsId: getId("TenantDirector"), permissionId: getId('CREATE_SEARCH') }, - - { createdAt, updatedAt, roles_permissionsId: getId("CampusManager"), permissionId: getId('CREATE_SEARCH') }, - - { createdAt, updatedAt, roles_permissionsId: getId("AcademicCoordinator"), permissionId: getId('CREATE_SEARCH') }, - - { createdAt, updatedAt, roles_permissionsId: getId("FinanceOfficer"), permissionId: getId('CREATE_SEARCH') }, - - - - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_USERS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_CAMPUSES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_ACADEMIC_YEARS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_GRADES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_SUBJECTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_STUDENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_GUARDIANS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_STAFF') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_CLASSES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_CLASS_ENROLLMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_CLASS_SUBJECTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_TIMETABLES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_TIMETABLE_PERIODS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_ATTENDANCE_SESSIONS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_ATTENDANCE_RECORDS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_FEE_PLANS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_INVOICES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_PAYMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_ASSESSMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_ASSESSMENT_RESULTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_MESSAGES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_MESSAGE_RECIPIENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('UPDATE_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('DELETE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_USERS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_USERS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ROLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ROLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ROLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ROLES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_PERMISSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_PERMISSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_PERMISSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_PERMISSIONS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ORGANIZATIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ORGANIZATIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ORGANIZATIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ORGANIZATIONS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_CAMPUSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_CAMPUSES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ACADEMIC_YEARS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ACADEMIC_YEARS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_GRADES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_GRADES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_SUBJECTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_STUDENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_STUDENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_GUARDIANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_GUARDIANS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_STAFF') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_STAFF') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_CLASSES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_CLASSES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_CLASS_ENROLLMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_CLASS_ENROLLMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_CLASS_SUBJECTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_CLASS_SUBJECTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_TIMETABLES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_TIMETABLES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_TIMETABLE_PERIODS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_TIMETABLE_PERIODS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ATTENDANCE_SESSIONS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ATTENDANCE_SESSIONS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ATTENDANCE_RECORDS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ATTENDANCE_RECORDS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_FEE_PLANS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_FEE_PLANS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_INVOICES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_INVOICES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_PAYMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_PAYMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ASSESSMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ASSESSMENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_ASSESSMENT_RESULTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_ASSESSMENT_RESULTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_MESSAGES') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_MESSAGES') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_MESSAGE_RECIPIENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_MESSAGE_RECIPIENTS') }, - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('UPDATE_DOCUMENTS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('DELETE_DOCUMENTS') }, - - - - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('READ_API_DOCS') }, - { createdAt, updatedAt, roles_permissionsId: getId("SuperAdmin"), permissionId: getId('CREATE_SEARCH') }, - - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_API_DOCS') }, - { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_SEARCH') }, + // 3. Role → permission matrix. + const links: Array<{ + createdAt: Date; + updatedAt: Date; + roles_permissionsId: string; + permissionId: string; + }> = []; + + const grant = (role: RoleName, names: readonly string[]): void => { + const rid = roleId.get(role); + if (!rid) return; + for (const name of names) { + const pid = permId.get(name); + if (pid) { + links.push({ + createdAt, + updatedAt, + roles_permissionsId: rid, + permissionId: pid, + }); + } + } + }; + + 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] ?? []); + } + // 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). + grant(ROLE_NAMES.OFFICE_MANAGER, [ + 'CREATE_POLICY_DOCUMENTS', + 'UPDATE_POLICY_DOCUMENTS', + 'DELETE_POLICY_DOCUMENTS', ]); + await queryInterface.bulkInsert('rolesPermissionsPermissions', links); - await queryInterface.sequelize.query(`UPDATE "users" SET "app_roleId"='${getId("SuperAdmin")}' WHERE "email"='super_admin@flatlogic.com'`); - await queryInterface.sequelize.query(`UPDATE "users" SET "app_roleId"='${getId("Administrator")}' WHERE "email"='admin@flatlogic.com'`); - - - - - - - await queryInterface.sequelize.query(`UPDATE "users" SET "app_roleId"='${getId("PlatformOwner")}' WHERE "email"='client@hello.com'`); - await queryInterface.sequelize.query(`UPDATE "users" SET "app_roleId"='${getId("TenantDirector")}' WHERE "email"='john@doe.com'`); - - - + // 4. Assign roles to the seeded fixture users (by id — robust to the + // configured super-admin email). + for (const fixture of SEED_ALL_USERS) { + const rid = roleId.get(fixture.role); + if (!rid) continue; + await queryInterface.sequelize.query( + `UPDATE "users" SET "app_roleId"=:rid WHERE "id"=:id`, + { replacements: { rid, id: fixture.id } }, + ); + } + }, -} + async down(queryInterface: QueryInterface) { + await queryInterface.bulkDelete('rolesPermissionsPermissions', {}); + await queryInterface.bulkDelete('permissions', {}); + await queryInterface.bulkDelete('roles', {}); + }, }; - diff --git a/backend/src/db/seeders/20260608103000-content-catalog.ts b/backend/src/db/seeders/20260608103000-content-catalog.ts index 87c8f2a..61e851e 100644 --- a/backend/src/db/seeders/20260608103000-content-catalog.ts +++ b/backend/src/db/seeders/20260608103000-content-catalog.ts @@ -28,7 +28,9 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { 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 }, - { content_type: 'safety-protocols', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyProtocols }, + // `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 }, diff --git a/backend/src/db/seeders/20260610020000-rbac-fixtures.ts b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts new file mode 100644 index 0000000..8fbce3c --- /dev/null +++ b/backend/src/db/seeders/20260610020000-rbac-fixtures.ts @@ -0,0 +1,141 @@ +import { v4 as uuid } from 'uuid'; +import { + Op, + QueryTypes, + type CreationAttributes, + type QueryInterface, +} from 'sequelize'; +import { + SEED_ORGANIZATION_ID, + SEED_ORGANIZATION_NAME, + SEED_ORGANIZATION_2_ID, + SEED_ORGANIZATION_2_NAME, + SEED_SECONDARY_OWNER, + SEED_CAMPUS_ID, + SEED_FIXTURE_USERS, +} from '@/shared/constants/seed-fixtures'; +import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses'; +import type { Organizations } from '@/db/models/organizations'; +import type { Staff } from '@/db/models/staff'; + +/** + * RBAC fixture links (Workstream 4): one company that owns the seeded campuses, + * the per-role users tied to the company/campus, and staff profiles covering + * every campus staff role on the `tigers` 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); + +export default { + up: async (queryInterface: QueryInterface) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + // 1. The companies (idempotent): the primary tenant and the second tenant + // used to prove cross-tenant isolation (Workstream 8). + const orgSeeds: CreationAttributes[] = [ + { id: SEED_ORGANIZATION_ID, name: SEED_ORGANIZATION_NAME, createdAt, updatedAt }, + { id: SEED_ORGANIZATION_2_ID, name: SEED_ORGANIZATION_2_NAME, createdAt, updatedAt }, + ]; + const existingOrgs = await queryInterface.sequelize.query<{ id: string }>( + `SELECT id FROM organizations WHERE id IN (:ids)`, + { + replacements: { ids: orgSeeds.map((org) => org.id) }, + type: QueryTypes.SELECT, + }, + ); + const existingOrgIds = new Set(existingOrgs.map((row) => row.id)); + const orgsToInsert = orgSeeds.filter((org) => !existingOrgIds.has(org.id as string)); + if (orgsToInsert.length > 0) { + 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 company owns the seeded campuses. + await queryInterface.sequelize.query( + `UPDATE "campuses" SET "organizationId" = :org WHERE "id" IN (:ids)`, + { replacements: { org: SEED_ORGANIZATION_ID, ids: campusIds } }, + ); + + // 3. Tie each fixture user to the company/campus per its scope. + for (const user of SEED_FIXTURE_USERS) { + await queryInterface.sequelize.query( + `UPDATE "users" SET "organizationId" = :org, "campusId" = :campus WHERE "id" = :id`, + { + replacements: { + org: user.organization ? SEED_ORGANIZATION_ID : null, + campus: user.campus ? SEED_CAMPUS_ID : null, + id: user.id, + }, + }, + ); + } + + // 4. 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, + campusId: SEED_CAMPUS_ID, + userId: user.id, + createdAt, + updatedAt, + })); + + if (staffRows.length > 0) { + await queryInterface.bulkInsert('staff', staffRows); + } + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete( + 'staff', + { userId: { [Op.in]: staffUserIds } }, + {}, + ); + await queryInterface.sequelize.query( + `UPDATE "users" SET "organizationId" = NULL, "campusId" = NULL WHERE "id" IN (:ids)`, + { + replacements: { + ids: [ + ...SEED_FIXTURE_USERS.map((user) => user.id), + SEED_SECONDARY_OWNER.id, + ], + }, + }, + ); + await queryInterface.sequelize.query( + `UPDATE "campuses" SET "organizationId" = NULL WHERE "id" IN (:ids)`, + { replacements: { ids: campusIds } }, + ); + await queryInterface.bulkDelete( + 'organizations', + { id: { [Op.in]: [SEED_ORGANIZATION_ID, SEED_ORGANIZATION_2_ID] } }, + {}, + ); + }, +}; diff --git a/backend/src/db/seeders/20260611050000-policy-documents-seed.ts b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts new file mode 100644 index 0000000..1955088 --- /dev/null +++ b/backend/src/db/seeders/20260611050000-policy-documents-seed.ts @@ -0,0 +1,131 @@ +import { v4 as uuid } from 'uuid'; +import { Op, type QueryInterface } from 'sequelize'; +import { + SEED_ORGANIZATION_ID, + SEED_CAMPUS_ID, + SEED_FIXTURE_USERS, +} from '@/shared/constants/seed-fixtures'; +import { + POLICY_DOCUMENT_CATEGORIES, + type PolicyDocumentCategory, +} from '@/shared/constants/policy-documents'; +import { formatPersonName } from '@/shared/constants/users'; +import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-catalog-seed-payloads'; + +/** + * Seeds the unified `policy_documents` for the Safety Protocols and Handbook & + * Policies pages (Workstream 11). Safety protocols reuse the existing + * content-catalog `safetyProtocols` payload (steps + autism considerations); + * a few handbook policies give the handbook page demo content. Authored by the + * seeded director on the primary org/campus. Idempotent by `importHash`. + */ +const DIRECTOR = SEED_FIXTURE_USERS.find((u) => u.role === 'director'); +const AUTHOR = DIRECTOR + ? formatPersonName(DIRECTOR.namePrefix, DIRECTOR.firstName, DIRECTOR.lastName) + : null; +const AUTHOR_ID = DIRECTOR?.id ?? null; + +interface SeedRow { + readonly importHash: string; + readonly title: string; + readonly category: PolicyDocumentCategory; + readonly tag: string; + readonly body: string | null; + readonly steps: readonly string[] | null; + readonly autismConsiderations: readonly string[] | null; +} + +const SAFETY_ROWS: SeedRow[] = CONTENT_CATALOG_SEED_PAYLOADS.safetyProtocols.map( + (protocol) => ({ + importHash: `policy-doc-safety-${protocol.id}`, + title: protocol.title, + category: POLICY_DOCUMENT_CATEGORIES.SAFETY_PROTOCOL, + tag: protocol.iconId, + body: null, + steps: protocol.steps, + autismConsiderations: protocol.autismConsiderations, + }), +); + +const HANDBOOK_ROWS: SeedRow[] = [ + { + importHash: 'policy-doc-handbook-behavior', + title: 'Student Behavior Intervention Policy', + category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY, + tag: 'Behavior', + body: 'All behavior interventions must follow the least-restrictive approach. Staff must document all incidents using the Behavior Incident Report form within 24 hours. Physical intervention is only permitted when there is imminent danger to the individual or others, and must follow de-escalation protocols. All physical interventions must be reported to administration immediately.', + steps: null, + autismConsiderations: null, + }, + { + importHash: 'policy-doc-handbook-attendance', + title: 'Attendance & Tardiness Policy', + category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY, + tag: 'Operations', + body: 'Staff record campus attendance daily by 9:00 AM. Tardiness beyond 15 minutes requires a note to the office manager. Patterns of absence are reviewed monthly with the director.', + steps: null, + autismConsiderations: null, + }, + { + importHash: 'policy-doc-handbook-parent-comm', + title: 'Parent Communication Standards', + category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY, + tag: 'Communication', + body: 'Use plain, respectful language with families. Acknowledge messages within one business day. Document significant conversations in the communication log.', + steps: null, + autismConsiderations: null, + }, + { + importHash: 'policy-doc-handbook-prof-dev', + title: 'Professional Development Requirements', + category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY, + tag: 'Professional Growth', + body: 'All instructional staff complete 20 hours of professional development annually, including at least 6 hours on autism-specific practices and de-escalation.', + steps: null, + autismConsiderations: null, + }, +]; + +const ALL_ROWS = [...SAFETY_ROWS, ...HANDBOOK_ROWS]; + +export default { + up: async (queryInterface: QueryInterface) => { + const now = new Date(); + const importHashes = ALL_ROWS.map((row) => row.importHash); + + await queryInterface.bulkDelete('policy_documents', { + importHash: { [Op.in]: importHashes }, + }); + + const rows = ALL_ROWS.map((row) => ({ + id: uuid(), + title: row.title, + body: row.body, + category: row.category, + tag: row.tag, + author: AUTHOR, + // JSONB columns: serialize for bulkInsert (Postgres casts JSON → jsonb). + steps: row.steps ? JSON.stringify(row.steps) : null, + autism_considerations: row.autismConsiderations + ? JSON.stringify(row.autismConsiderations) + : null, + version: 1, + active: true, + importHash: row.importHash, + organizationId: SEED_ORGANIZATION_ID, + campusId: SEED_CAMPUS_ID, + createdById: AUTHOR_ID, + updatedById: AUTHOR_ID, + createdAt: now, + updatedAt: now, + })); + + await queryInterface.bulkInsert('policy_documents', rows); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete('policy_documents', { + importHash: { [Op.in]: ALL_ROWS.map((row) => row.importHash) }, + }); + }, +}; 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 bc3da1f..d621e6b 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 @@ -80,10 +80,10 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ ], safetyQbsQuiz: { id: '1', - title: 'De-Escalation Techniques Review', + title: 'Behavior Management Review', focus: 'de-escalation', weeklyFocus: { - title: 'This Week\'s Focus: De-Escalation Techniques', + title: 'This Week\'s Focus: Behavior Management', description: 'Remember: Reduce demands first. Use low, slow, calm voice. Minimal words. Give space. Do NOT process during crisis.', }, keyReminders: [ diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d88af5..8cb552f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,10 @@ import swaggerUI from 'swagger-ui-express'; import swaggerJsDoc from 'swagger-jsdoc'; import config from '@/shared/config'; import csrfOrigin from '@/middlewares/csrf-origin'; +import { + AUTH_COOKIE_NAME, + AUTH_REFRESH_COOKIE_NAME, +} from '@/shared/constants/auth'; import ForbiddenError from '@/shared/errors/forbidden'; import { errorHandler, @@ -15,6 +19,24 @@ import { import logger from '@/shared/logger'; import '@/auth/auth'; +// Global error handlers to prevent server crashes from unhandled errors +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception - server continues running:', { + message: error.message, + stack: error.stack, + name: error.name, + }); +}); + +process.on('unhandledRejection', (reason: unknown) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + logger.error('Unhandled Promise Rejection - server continues running:', { + message: error.message, + stack: error.stack, + name: error.name, + }); +}); + import authRoutes from '@/routes/auth'; import fileRoutes from '@/routes/file'; import searchRoutes from '@/routes/search'; @@ -29,8 +51,6 @@ import campusesRoutes from '@/routes/campuses'; import academicYearsRoutes from '@/routes/academic_years'; import gradesRoutes from '@/routes/grades'; import subjectsRoutes from '@/routes/subjects'; -import studentsRoutes from '@/routes/students'; -import guardiansRoutes from '@/routes/guardians'; import staffRoutes from '@/routes/staff'; import classesRoutes from '@/routes/classes'; import classEnrollmentsRoutes from '@/routes/class_enrollments'; @@ -39,14 +59,10 @@ import timetablesRoutes from '@/routes/timetables'; import timetablePeriodsRoutes from '@/routes/timetable_periods'; import attendanceSessionsRoutes from '@/routes/attendance_sessions'; import attendanceRecordsRoutes from '@/routes/attendance_records'; -import feePlansRoutes from '@/routes/fee_plans'; -import invoicesRoutes from '@/routes/invoices'; -import paymentsRoutes from '@/routes/payments'; import assessmentsRoutes from '@/routes/assessments'; import assessmentResultsRoutes from '@/routes/assessment_results'; import messagesRoutes from '@/routes/messages'; import messageRecipientsRoutes from '@/routes/message_recipients'; -import documentsRoutes from '@/routes/documents'; import frameEntriesRoutes from '@/routes/frame_entries'; import userProgressRoutes from '@/routes/user_progress'; import safetyQuizResultsRoutes from '@/routes/safety_quiz_results'; @@ -55,6 +71,9 @@ import communicationsRoutes from '@/routes/communications'; import personalityQuizResultsRoutes from '@/routes/personality_quiz_results'; import campusAttendanceRoutes from '@/routes/campus_attendance'; import staffAttendanceRoutes from '@/routes/staff_attendance'; +import policyDocumentsRoutes from '@/routes/policy_documents'; +import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments'; +import audioFilesRoutes from '@/routes/audio_files'; const app = express(); @@ -70,9 +89,28 @@ const swaggerOptions: swaggerJsDoc.Options = { openapi: '3.0.0', info: { version: '1.0.0', - title: 'School Chain Manager', - description: - 'School Chain Manager Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', + title: 'School Chain Manager API', + description: [ + 'REST API for the School Chain Manager backend.', + '', + '**Authentication is cookie-based.** Sign-in sets two HttpOnly cookies:', + `a short-lived access cookie (\`${AUTH_COOKIE_NAME}\`, a signed JWT) and a`, + `long-lived opaque refresh cookie (\`${AUTH_REFRESH_COOKIE_NAME}\`). The`, + 'browser never reads or sends tokens manually; they travel automatically', + 'as cookies. The Swagger "Authorize" cookie field is for tooling only.', + '', + '**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.', + '', + '**Generic-CRUD convention.** Entity routers (`users`, `roles`,', + '`campuses`, …) expose the same shape: `GET /` (list → `ListResponse`),', + '`GET /count`, `GET /autocomplete`, `GET /:id`, `POST /`,', + '`POST /bulk-import`, `PUT /:id`, `DELETE /:id`, `POST /deleteByIds`.', + 'Errors share the `Error` schema.', + ].join('\n'), }, servers: [ { @@ -82,19 +120,89 @@ const swaggerOptions: swaggerJsDoc.Options = { ], components: { securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: AUTH_COOKIE_NAME, + description: + 'HttpOnly access cookie set on sign-in/refresh. Sent automatically by the browser.', + }, + }, + schemas: { + Error: { + type: 'object', + description: + 'Standard error body produced by the terminal error handler.', + properties: { + message: { type: 'string' }, + code: { type: 'string', nullable: true }, + details: { nullable: true }, + }, + required: ['message'], + }, + ListResponse: { + type: 'object', + description: 'Standard paginated list payload.', + properties: { + rows: { type: 'array', items: { type: 'object' } }, + count: { type: 'integer' }, + }, + required: ['rows', 'count'], + }, + UserProfile: { + type: 'object', + description: + 'The authenticated user profile returned by sign-in, refresh, and `/me`.', + properties: { + id: { type: 'string', format: 'uuid' }, + email: { type: 'string' }, + name_prefix: { type: 'string', nullable: true }, + firstName: { type: 'string', nullable: true }, + lastName: { type: 'string', nullable: true }, + organizationId: { type: 'string', format: 'uuid', nullable: true }, + app_role: { + type: 'object', + nullable: true, + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + scope: { type: 'string' }, + globalAccess: { type: 'boolean' }, + }, + }, + campusId: { type: 'string', format: 'uuid', nullable: true }, + permissions: { type: 'array', items: { type: 'string' } }, + }, }, }, responses: { UnauthorizedError: { - description: 'Access token is missing or invalid', + description: 'No valid session cookie (401).', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + ForbiddenError: { + description: 'Authenticated but lacks the required permission (403).', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + ValidationError: { + description: 'Invalid input or not found (400).', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, }, }, }, - security: [{ bearerAuth: [] }], + security: [{ cookieAuth: [] }], }, apis: ['./src/routes/*.ts'], }; @@ -136,8 +244,6 @@ 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/students', authenticated, studentsRoutes); -app.use('/api/guardians', authenticated, guardiansRoutes); app.use('/api/staff', authenticated, staffRoutes); app.use('/api/classes', authenticated, classesRoutes); app.use('/api/class_enrollments', authenticated, classEnrollmentsRoutes); @@ -146,14 +252,10 @@ app.use('/api/timetables', authenticated, timetablesRoutes); app.use('/api/timetable_periods', authenticated, timetablePeriodsRoutes); app.use('/api/attendance_sessions', authenticated, attendanceSessionsRoutes); app.use('/api/attendance_records', authenticated, attendanceRecordsRoutes); -app.use('/api/fee_plans', authenticated, feePlansRoutes); -app.use('/api/invoices', authenticated, invoicesRoutes); -app.use('/api/payments', authenticated, paymentsRoutes); app.use('/api/assessments', authenticated, assessmentsRoutes); app.use('/api/assessment_results', authenticated, assessmentResultsRoutes); app.use('/api/messages', authenticated, messagesRoutes); app.use('/api/message_recipients', authenticated, messageRecipientsRoutes); -app.use('/api/documents', authenticated, documentsRoutes); app.use('/api/frame_entries', authenticated, frameEntriesRoutes); app.use('/api/user_progress', authenticated, userProgressRoutes); app.use('/api/safety_quiz_results', authenticated, safetyQuizResultsRoutes); @@ -167,6 +269,9 @@ app.use( app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes); app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes); app.use('/api/content-catalog', authenticated, contentCatalogRoutes); +app.use('/api/policy_documents', authenticated, policyDocumentsRoutes); +app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes); +app.use('/api/audio_files', authenticated, audioFilesRoutes); app.use('/api/search', authenticated, searchRoutes); // Unmatched API routes → centralized 404 (the SPA fallback below handles the rest). diff --git a/backend/src/middlewares/check-permissions.ts b/backend/src/middlewares/check-permissions.ts index 5b920dc..ab0786d 100644 --- a/backend/src/middlewares/check-permissions.ts +++ b/backend/src/middlewares/check-permissions.ts @@ -3,14 +3,14 @@ import logger from '@/shared/logger'; import type { RequestHandler } from 'express'; import ValidationError from '@/shared/errors/validation'; import RolesDBApi from '@/db/api/roles'; -import { SPECIAL_ROLE_NAMES } from '@/shared/constants/roles'; +import { ROLE_NAMES } from '@/shared/constants/roles'; -// Cache for the 'Public' role record, loaded once at startup. +// Cache for the unauthenticated fallback `guest` role, loaded once at startup. let publicRoleCache: Record | null = null; async function fetchAndCachePublicRole(): Promise { try { - publicRoleCache = await RolesDBApi.findBy({ name: SPECIAL_ROLE_NAMES.PUBLIC }); + publicRoleCache = await RolesDBApi.findBy({ name: ROLE_NAMES.GUEST }); if (!publicRoleCache) { logger.error( @@ -83,15 +83,24 @@ function checkPermissions(permission: string): RequestHandler { return async (req, _res, next) => { const currentUser = req.currentUser; - // 1. Self-access bypass. + // 1. Self-access bypass — read-only, on the caller's own record by route + // param. (A `req.body.id` bypass would let any user pass a write guard by + // putting their own id in the body; profile self-edits go through the + // dedicated `/api/auth/profile` endpoint.) if ( currentUser && - (currentUser.id === req.params.id || currentUser.id === req.body?.id) + req.method === 'GET' && + currentUser.id === req.params.id ) { return next(); } - // 2. Custom (per-user) permissions. + // 2. Global-access roles (system scope) bypass per-permission checks. + if (currentUser?.app_role?.globalAccess === true) { + return next(); + } + + // 3. Custom (per-user) permissions. if (currentUser) { const customPermissions = currentUser.custom_permissions ?? []; if (customPermissions.some((cp) => cp.name === permission)) { @@ -108,7 +117,7 @@ function checkPermissions(permission: string): RequestHandler { logger.error( 'Public role cache is empty. Attempting synchronous fetch...', ); - effectiveRole = await RolesDBApi.findBy({ name: SPECIAL_ROLE_NAMES.PUBLIC }); + effectiveRole = await RolesDBApi.findBy({ name: ROLE_NAMES.GUEST }); if (!effectiveRole) { return next( new Error( diff --git a/backend/src/routes/academic_years.ts b/backend/src/routes/academic_years.ts index 20909c6..594d56d 100644 --- a/backend/src/routes/academic_years.ts +++ b/backend/src/routes/academic_years.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/academic_years: + * get: + * tags: [Academic Years] + * summary: List academic years (tenant-scoped) + * description: Requires READ_ACADEMIC_YEARS. + * 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: [Academic Years] + * summary: Create a academic years record + * description: Requires CREATE_ACADEMIC_YEARS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/academic_years/count: + * get: + * tags: [Academic Years] + * summary: Count academic years + * description: Requires READ_ACADEMIC_YEARS. + * responses: + * 200: { description: Count. } + * /api/academic_years/autocomplete: + * get: + * tags: [Academic Years] + * summary: Autocomplete academic years + * description: Requires READ_ACADEMIC_YEARS. + * responses: + * 200: { description: Autocomplete results. } + * /api/academic_years/bulk-import: + * post: + * tags: [Academic Years] + * summary: Bulk-import academic years from CSV + * description: Requires CREATE_ACADEMIC_YEARS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/academic_years/deleteByIds: + * post: + * tags: [Academic Years] + * summary: Delete multiple academic years records + * description: Requires DELETE_ACADEMIC_YEARS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/academic_years/{id}: + * get: + * tags: [Academic Years] + * summary: Get a academic years record by id + * description: Requires READ_ACADEMIC_YEARS. + * 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: [Academic Years] + * summary: Update a academic years record + * description: Requires UPDATE_ACADEMIC_YEARS. + * 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: [Academic Years] + * summary: Delete a academic years record + * description: Requires DELETE_ACADEMIC_YEARS. + * 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/academic_years.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/assessment_results.ts b/backend/src/routes/assessment_results.ts index a3841ff..2e91c9d 100644 --- a/backend/src/routes/assessment_results.ts +++ b/backend/src/routes/assessment_results.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/assessment_results: + * get: + * tags: [Assessment Results] + * summary: List assessment results (tenant-scoped) + * description: Requires READ_ASSESSMENT_RESULTS. + * 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: [Assessment Results] + * summary: Create a assessment results record + * description: Requires CREATE_ASSESSMENT_RESULTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessment_results/count: + * get: + * tags: [Assessment Results] + * summary: Count assessment results + * description: Requires READ_ASSESSMENT_RESULTS. + * responses: + * 200: { description: Count. } + * /api/assessment_results/autocomplete: + * get: + * tags: [Assessment Results] + * summary: Autocomplete assessment results + * description: Requires READ_ASSESSMENT_RESULTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/assessment_results/bulk-import: + * post: + * tags: [Assessment Results] + * summary: Bulk-import assessment results from CSV + * description: Requires CREATE_ASSESSMENT_RESULTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessment_results/deleteByIds: + * post: + * tags: [Assessment Results] + * summary: Delete multiple assessment results records + * description: Requires DELETE_ASSESSMENT_RESULTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessment_results/{id}: + * get: + * tags: [Assessment Results] + * summary: Get a assessment results record by id + * description: Requires READ_ASSESSMENT_RESULTS. + * 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: [Assessment Results] + * summary: Update a assessment results record + * description: Requires UPDATE_ASSESSMENT_RESULTS. + * 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: [Assessment Results] + * summary: Delete a assessment results record + * description: Requires DELETE_ASSESSMENT_RESULTS. + * 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/assessment_results.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/assessments.ts b/backend/src/routes/assessments.ts index e0ddf0a..b21b86c 100644 --- a/backend/src/routes/assessments.ts +++ b/backend/src/routes/assessments.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/assessments: + * get: + * tags: [Assessments] + * summary: List assessments (tenant-scoped) + * description: Requires READ_ASSESSMENTS. + * 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: [Assessments] + * summary: Create a assessments record + * description: Requires CREATE_ASSESSMENTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessments/count: + * get: + * tags: [Assessments] + * summary: Count assessments + * description: Requires READ_ASSESSMENTS. + * responses: + * 200: { description: Count. } + * /api/assessments/autocomplete: + * get: + * tags: [Assessments] + * summary: Autocomplete assessments + * description: Requires READ_ASSESSMENTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/assessments/bulk-import: + * post: + * tags: [Assessments] + * summary: Bulk-import assessments from CSV + * description: Requires CREATE_ASSESSMENTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessments/deleteByIds: + * post: + * tags: [Assessments] + * summary: Delete multiple assessments records + * description: Requires DELETE_ASSESSMENTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/assessments/{id}: + * get: + * tags: [Assessments] + * summary: Get a assessments record by id + * description: Requires READ_ASSESSMENTS. + * 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: [Assessments] + * summary: Update a assessments record + * description: Requires UPDATE_ASSESSMENTS. + * 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: [Assessments] + * summary: Delete a assessments record + * description: Requires DELETE_ASSESSMENTS. + * 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/assessments.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/attendance_records.ts b/backend/src/routes/attendance_records.ts index 1f68244..784532b 100644 --- a/backend/src/routes/attendance_records.ts +++ b/backend/src/routes/attendance_records.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/attendance_records: + * get: + * tags: [Attendance Records] + * summary: List attendance records (tenant-scoped) + * description: Requires READ_ATTENDANCE_RECORDS. + * 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: [Attendance Records] + * summary: Create a attendance records record + * description: Requires CREATE_ATTENDANCE_RECORDS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_records/count: + * get: + * tags: [Attendance Records] + * summary: Count attendance records + * description: Requires READ_ATTENDANCE_RECORDS. + * responses: + * 200: { description: Count. } + * /api/attendance_records/autocomplete: + * get: + * tags: [Attendance Records] + * summary: Autocomplete attendance records + * description: Requires READ_ATTENDANCE_RECORDS. + * responses: + * 200: { description: Autocomplete results. } + * /api/attendance_records/bulk-import: + * post: + * tags: [Attendance Records] + * summary: Bulk-import attendance records from CSV + * description: Requires CREATE_ATTENDANCE_RECORDS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_records/deleteByIds: + * post: + * tags: [Attendance Records] + * summary: Delete multiple attendance records records + * description: Requires DELETE_ATTENDANCE_RECORDS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_records/{id}: + * get: + * tags: [Attendance Records] + * summary: Get a attendance records record by id + * description: Requires READ_ATTENDANCE_RECORDS. + * 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: [Attendance Records] + * summary: Update a attendance records record + * description: Requires UPDATE_ATTENDANCE_RECORDS. + * 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: [Attendance Records] + * summary: Delete a attendance records record + * description: Requires DELETE_ATTENDANCE_RECORDS. + * 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/attendance_records.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/attendance_sessions.ts b/backend/src/routes/attendance_sessions.ts index 71ed935..db95897 100644 --- a/backend/src/routes/attendance_sessions.ts +++ b/backend/src/routes/attendance_sessions.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/attendance_sessions: + * get: + * tags: [Attendance Sessions] + * summary: List attendance sessions (tenant-scoped) + * description: Requires READ_ATTENDANCE_SESSIONS. + * 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: [Attendance Sessions] + * summary: Create a attendance sessions record + * description: Requires CREATE_ATTENDANCE_SESSIONS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_sessions/count: + * get: + * tags: [Attendance Sessions] + * summary: Count attendance sessions + * description: Requires READ_ATTENDANCE_SESSIONS. + * responses: + * 200: { description: Count. } + * /api/attendance_sessions/autocomplete: + * get: + * tags: [Attendance Sessions] + * summary: Autocomplete attendance sessions + * description: Requires READ_ATTENDANCE_SESSIONS. + * responses: + * 200: { description: Autocomplete results. } + * /api/attendance_sessions/bulk-import: + * post: + * tags: [Attendance Sessions] + * summary: Bulk-import attendance sessions from CSV + * description: Requires CREATE_ATTENDANCE_SESSIONS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_sessions/deleteByIds: + * post: + * tags: [Attendance Sessions] + * summary: Delete multiple attendance sessions records + * description: Requires DELETE_ATTENDANCE_SESSIONS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/attendance_sessions/{id}: + * get: + * tags: [Attendance Sessions] + * summary: Get a attendance sessions record by id + * description: Requires READ_ATTENDANCE_SESSIONS. + * 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: [Attendance Sessions] + * summary: Update a attendance sessions record + * description: Requires UPDATE_ATTENDANCE_SESSIONS. + * 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: [Attendance Sessions] + * summary: Delete a attendance sessions record + * description: Requires DELETE_ATTENDANCE_SESSIONS. + * 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/attendance_sessions.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/audio_files.ts b/backend/src/routes/audio_files.ts new file mode 100644 index 0000000..059bbe1 --- /dev/null +++ b/backend/src/routes/audio_files.ts @@ -0,0 +1,69 @@ +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 audio_files from '@/api/controllers/audio_files.controller'; + +const router = express.Router(); + +const canRead = permissions.checkPermissions(FEATURE_PERMISSIONS.READ_AUDIO_FILES); +const canManage = permissions.checkPermissions( + FEATURE_PERMISSIONS.MANAGE_AUDIO_FILES, +); + +/** + * @openapi + * /api/audio_files: + * get: + * tags: [Audio Library] + * summary: List playable audio (campus uploads + global defaults) + * description: Requires READ_AUDIO_FILES (the four campus staff roles). + * responses: + * 200: + * description: Audio files. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Audio Library] + * summary: Add an uploaded audio file (director / office_manager / teacher) + * description: > + * Requires MANAGE_AUDIO_FILES. The binary is uploaded via the file + * subsystem first; `url` references it. The row is campus-scoped. + * responses: + * 200: { description: Created. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/audio_files/{id}: + * put: + * tags: [Audio Library] + * summary: Update an audio file (own campus; not a global default) + * description: Requires MANAGE_AUDIO_FILES. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Updated. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * delete: + * tags: [Audio Library] + * summary: Delete an audio file (own campus; not a global default) + * description: Requires MANAGE_AUDIO_FILES. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get('/', canRead, wrapAsync(audio_files.list)); +router.post('/', canManage, wrapAsync(audio_files.create)); +router.put('/:id', canManage, wrapAsync(audio_files.update)); +router.delete('/:id', canManage, wrapAsync(audio_files.remove)); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2169fcb..98729a1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -7,37 +7,281 @@ const router = express.Router(); const authenticated = passport.authenticate('jwt', { session: false }); +/** + * @openapi + * tags: + * - name: Auth + * description: > + * Cookie-based authentication. Sign-in/refresh set the HttpOnly access and + * refresh cookies; sign-out clears them. The public routes (`/signin/local`, + * `/refresh`, `/signout`, `/password-reset`, `/signup`, `/verify-email`, + * `/send-password-reset-email`, `/email-configured`, OAuth) take no session; + * the rest require the access cookie. + */ + +/** + * @openapi + * /api/auth/signin/local: + * post: + * tags: [Auth] + * summary: Sign in with email + password + * description: > + * Validates credentials, then sets the access and refresh HttpOnly cookies + * and returns the authenticated user profile. Public (no session required). + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string } + * password: { type: string, format: password } + * responses: + * 200: + * description: Signed in; session cookies set. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/UserProfile' } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ router.post('/signin/local', wrapAsync(auth.signinLocal)); + +/** + * @openapi + * /api/auth/refresh: + * post: + * tags: [Auth] + * summary: Rotate the session from the refresh cookie + * description: > + * Reads the refresh cookie, rotates the refresh-token row, re-issues both + * cookies, and returns the user profile. Public (uses the cookie, not a + * session). + * security: [] + * responses: + * 200: + * description: Session refreshed; new cookies set. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/UserProfile' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.post('/refresh', wrapAsync(auth.refresh)); + +/** + * @openapi + * /api/auth/signout: + * post: + * tags: [Auth] + * summary: Revoke the session and clear cookies + * security: [] + * responses: + * 204: { description: Signed out; cookies cleared. } + */ router.post('/signout', wrapAsync(auth.signout)); + +/** + * @openapi + * /api/auth/me: + * get: + * tags: [Auth] + * summary: Current authenticated user profile + * description: Returns the profile (incl. resolved effective `permissions`). + * responses: + * 200: + * description: The current user. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/UserProfile' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.get('/me', authenticated, wrapAsync(auth.me)); + +/** + * @openapi + * /api/auth/password-reset: + * put: + * tags: [Auth] + * summary: Complete a password reset with a token + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [token, password] + * properties: + * token: { type: string } + * password: { type: string, format: password } + * responses: + * 200: { description: Password reset. } + * 400: { $ref: '#/components/responses/ValidationError' } + */ router.put('/password-reset', wrapAsync(auth.passwordReset)); + +/** + * @openapi + * /api/auth/password-update: + * put: + * tags: [Auth] + * summary: Update the current user's password + * responses: + * 200: { description: Password updated. } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.put('/password-update', authenticated, wrapAsync(auth.passwordUpdate)); + +/** + * @openapi + * /api/auth/send-email-address-verification-email: + * post: + * tags: [Auth] + * summary: Send the current user an email-verification link + * responses: + * 200: { description: Verification email sent (if email is configured). } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.post( '/send-email-address-verification-email', authenticated, wrapAsync(auth.sendEmailVerification), ); + +/** + * @openapi + * /api/auth/send-password-reset-email: + * post: + * tags: [Auth] + * summary: Send a password-reset email + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: { type: string } + * responses: + * 200: { description: Reset email sent (if email is configured). } + */ router.post('/send-password-reset-email', wrapAsync(auth.sendPasswordResetEmail)); + +/** + * @openapi + * /api/auth/signup: + * post: + * tags: [Auth] + * summary: Self-service signup (template endpoint; gated by product scope) + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string } + * password: { type: string, format: password } + * responses: + * 200: + * description: Signed up; session cookies set. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/UserProfile' } + * 400: { $ref: '#/components/responses/ValidationError' } + */ router.post('/signup', wrapAsync(auth.signup)); + +/** + * @openapi + * /api/auth/profile: + * put: + * tags: [Auth] + * summary: Update the current user's own profile + * responses: + * 200: + * description: Updated profile. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/UserProfile' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.put('/profile', authenticated, wrapAsync(auth.updateProfile)); + +/** + * @openapi + * /api/auth/verify-email: + * put: + * tags: [Auth] + * summary: Verify an email address with a token + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [token] + * properties: + * token: { type: string } + * responses: + * 200: { description: Email verified. } + * 400: { $ref: '#/components/responses/ValidationError' } + */ router.put('/verify-email', wrapAsync(auth.verifyEmail)); + +/** + * @openapi + * /api/auth/email-configured: + * get: + * tags: [Auth] + * summary: Whether transactional email is configured + * security: [] + * responses: + * 200: + * description: Email-configured flag. + * content: + * application/json: + * schema: + * type: object + * properties: + * emailConfigured: { type: boolean } + */ router.get('/email-configured', auth.emailConfigured); +/** + * @openapi + * /api/auth/signin/google: + * get: + * tags: [Auth] + * summary: Begin Google OAuth sign-in (redirect) + * security: [] + * responses: + * 302: { description: Redirect to Google. } + */ router.get('/signin/google', auth.googleSignin); + +/** + * @openapi + * /api/auth/signin/google/callback: + * get: + * tags: [Auth] + * summary: Google OAuth callback — sets cookies, redirects to the UI + * security: [] + * responses: + * 302: { description: Redirect to the UI with session cookies set. } + */ router.get( '/signin/google/callback', passport.authenticate('google', { failureRedirect: '/login', session: false }), wrapAsync(auth.googleCallback), ); -router.get('/signin/microsoft', auth.microsoftSignin); -router.get( - '/signin/microsoft/callback', - passport.authenticate('microsoft', { - failureRedirect: '/login', - session: false, - }), - wrapAsync(auth.microsoftCallback), -); export default router; diff --git a/backend/src/routes/campus_attendance.ts b/backend/src/routes/campus_attendance.ts index f0957e8..760c41d 100644 --- a/backend/src/routes/campus_attendance.ts +++ b/backend/src/routes/campus_attendance.ts @@ -1,14 +1,78 @@ 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 campusAttendance from '@/api/controllers/campus_attendance.controller'; const router = express.Router(); -router.get('/configs', wrapAsync(campusAttendance.listConfigs)); -router.put('/configs/:campusKey', wrapAsync(campusAttendance.upsertConfig)); -router.get('/summaries', wrapAsync(campusAttendance.listSummaries)); +/** + * @openapi + * /api/campus_attendance/configs: + * get: + * tags: [Campus Attendance] + * summary: List campus attendance configs + * description: Requires `READ_ATTENDANCE`. + * responses: + * 200: { description: Configs. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campus_attendance/configs/{campusKey}: + * put: + * tags: [Campus Attendance] + * summary: Upsert a campus attendance config + * description: Requires the `FILL_ATTENDANCE` action permission. + * parameters: + * - in: path + * name: campusKey + * required: true + * schema: { type: string } + * responses: + * 200: { description: Upserted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campus_attendance/summaries: + * get: + * tags: [Campus Attendance] + * summary: List campus daily attendance summaries + * description: Requires `READ_ATTENDANCE`. + * responses: + * 200: { description: Summaries. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campus_attendance/summaries/{campusKey}/{date}: + * put: + * tags: [Campus Attendance] + * summary: Upsert a campus daily attendance summary + * description: Requires the `FILL_ATTENDANCE` action permission. + * parameters: + * - in: path + * name: campusKey + * required: true + * schema: { type: string } + * - in: path + * name: date + * required: true + * schema: { type: string, format: date } + * responses: + * 200: { description: Upserted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get( + '/configs', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), + wrapAsync(campusAttendance.listConfigs), +); +router.put( + '/configs/:campusKey', + permissions.checkPermissions(FEATURE_PERMISSIONS.FILL_ATTENDANCE), + wrapAsync(campusAttendance.upsertConfig), +); +router.get( + '/summaries', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), + wrapAsync(campusAttendance.listSummaries), +); router.put( '/summaries/:campusKey/:date', + permissions.checkPermissions(FEATURE_PERMISSIONS.FILL_ATTENDANCE), wrapAsync(campusAttendance.upsertSummary), ); diff --git a/backend/src/routes/campuses.ts b/backend/src/routes/campuses.ts index 059e78d..6ed661d 100644 --- a/backend/src/routes/campuses.ts +++ b/backend/src/routes/campuses.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/campuses: + * get: + * tags: [Campuses] + * summary: List campuses (tenant-scoped) + * description: Requires READ_CAMPUSES. + * 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: [Campuses] + * summary: Create a campuses record + * description: Requires CREATE_CAMPUSES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campuses/count: + * get: + * tags: [Campuses] + * summary: Count campuses + * description: Requires READ_CAMPUSES. + * responses: + * 200: { description: Count. } + * /api/campuses/autocomplete: + * get: + * tags: [Campuses] + * summary: Autocomplete campuses + * description: Requires READ_CAMPUSES. + * responses: + * 200: { description: Autocomplete results. } + * /api/campuses/bulk-import: + * post: + * tags: [Campuses] + * summary: Bulk-import campuses from CSV + * description: Requires CREATE_CAMPUSES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campuses/deleteByIds: + * post: + * tags: [Campuses] + * summary: Delete multiple campuses records + * description: Requires DELETE_CAMPUSES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/campuses/{id}: + * get: + * tags: [Campuses] + * summary: Get a campuses record by id + * description: Requires READ_CAMPUSES. + * 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: [Campuses] + * summary: Update a campuses record + * description: Requires UPDATE_CAMPUSES. + * 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: [Campuses] + * summary: Delete a campuses record + * description: Requires DELETE_CAMPUSES. + * 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/campuses.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/class_enrollments.ts b/backend/src/routes/class_enrollments.ts index 67bbd35..498848b 100644 --- a/backend/src/routes/class_enrollments.ts +++ b/backend/src/routes/class_enrollments.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/class_enrollments: + * get: + * tags: [Class Enrollments] + * summary: List class enrollments (tenant-scoped) + * description: Requires READ_CLASS_ENROLLMENTS. + * 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: [Class Enrollments] + * summary: Create a class enrollments record + * description: Requires CREATE_CLASS_ENROLLMENTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_enrollments/count: + * get: + * tags: [Class Enrollments] + * summary: Count class enrollments + * description: Requires READ_CLASS_ENROLLMENTS. + * responses: + * 200: { description: Count. } + * /api/class_enrollments/autocomplete: + * get: + * tags: [Class Enrollments] + * summary: Autocomplete class enrollments + * description: Requires READ_CLASS_ENROLLMENTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/class_enrollments/bulk-import: + * post: + * tags: [Class Enrollments] + * summary: Bulk-import class enrollments from CSV + * description: Requires CREATE_CLASS_ENROLLMENTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_enrollments/deleteByIds: + * post: + * tags: [Class Enrollments] + * summary: Delete multiple class enrollments records + * description: Requires DELETE_CLASS_ENROLLMENTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_enrollments/{id}: + * get: + * tags: [Class Enrollments] + * summary: Get a class enrollments record by id + * description: Requires READ_CLASS_ENROLLMENTS. + * 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: [Class Enrollments] + * summary: Update a class enrollments record + * description: Requires UPDATE_CLASS_ENROLLMENTS. + * 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: [Class Enrollments] + * summary: Delete a class enrollments record + * description: Requires DELETE_CLASS_ENROLLMENTS. + * 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/class_enrollments.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/class_subjects.ts b/backend/src/routes/class_subjects.ts index 33c57f4..4f4acbf 100644 --- a/backend/src/routes/class_subjects.ts +++ b/backend/src/routes/class_subjects.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/class_subjects: + * get: + * tags: [Class Subjects] + * summary: List class subjects (tenant-scoped) + * description: Requires READ_CLASS_SUBJECTS. + * 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: [Class Subjects] + * summary: Create a class subjects record + * description: Requires CREATE_CLASS_SUBJECTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_subjects/count: + * get: + * tags: [Class Subjects] + * summary: Count class subjects + * description: Requires READ_CLASS_SUBJECTS. + * responses: + * 200: { description: Count. } + * /api/class_subjects/autocomplete: + * get: + * tags: [Class Subjects] + * summary: Autocomplete class subjects + * description: Requires READ_CLASS_SUBJECTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/class_subjects/bulk-import: + * post: + * tags: [Class Subjects] + * summary: Bulk-import class subjects from CSV + * description: Requires CREATE_CLASS_SUBJECTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_subjects/deleteByIds: + * post: + * tags: [Class Subjects] + * summary: Delete multiple class subjects records + * description: Requires DELETE_CLASS_SUBJECTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/class_subjects/{id}: + * get: + * tags: [Class Subjects] + * summary: Get a class subjects record by id + * description: Requires READ_CLASS_SUBJECTS. + * 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: [Class Subjects] + * summary: Update a class subjects record + * description: Requires UPDATE_CLASS_SUBJECTS. + * 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: [Class Subjects] + * summary: Delete a class subjects record + * description: Requires DELETE_CLASS_SUBJECTS. + * 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/class_subjects.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index 37bf922..7a46ce1 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/classes: + * get: + * tags: [Classes] + * summary: List classes (tenant-scoped) + * description: Requires READ_CLASSES. + * 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: [Classes] + * summary: Create a classes record + * description: Requires CREATE_CLASSES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/classes/count: + * get: + * tags: [Classes] + * summary: Count classes + * description: Requires READ_CLASSES. + * responses: + * 200: { description: Count. } + * /api/classes/autocomplete: + * get: + * tags: [Classes] + * summary: Autocomplete classes + * description: Requires READ_CLASSES. + * responses: + * 200: { description: Autocomplete results. } + * /api/classes/bulk-import: + * post: + * tags: [Classes] + * summary: Bulk-import classes from CSV + * description: Requires CREATE_CLASSES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/classes/deleteByIds: + * post: + * tags: [Classes] + * summary: Delete multiple classes records + * description: Requires DELETE_CLASSES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/classes/{id}: + * get: + * tags: [Classes] + * summary: Get a classes record by id + * description: Requires READ_CLASSES. + * 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: [Classes] + * summary: Update a classes record + * description: Requires UPDATE_CLASSES. + * 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: [Classes] + * summary: Delete a classes record + * description: Requires DELETE_CLASSES. + * 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/classes.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/communications.ts b/backend/src/routes/communications.ts index 75271a3..74bb578 100644 --- a/backend/src/routes/communications.ts +++ b/backend/src/routes/communications.ts @@ -1,12 +1,61 @@ 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 communications from '@/api/controllers/communications.controller'; const router = express.Router(); -router.get('/parent-messages', wrapAsync(communications.listParentMessages)); +/** + * @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] + * summary: List internal communication events + * description: Requires `READ_INTERNAL_COMM`. + * responses: + * 200: + * description: Events. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Communications] + * summary: Create an internal event (manager-only; service-gated) + * responses: + * 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', wrapAsync(communications.listEvents)); +router.get( + '/events', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_INTERNAL_COMM), + wrapAsync(communications.listEvents), +); router.post('/events', wrapAsync(communications.createEvent)); export default router; diff --git a/backend/src/routes/content_catalog.ts b/backend/src/routes/content_catalog.ts index b7c4c04..c24e865 100644 --- a/backend/src/routes/content_catalog.ts +++ b/backend/src/routes/content_catalog.ts @@ -4,6 +4,59 @@ import * as content_catalog from '@/api/controllers/content_catalog.controller'; const router = express.Router(); +/** + * @openapi + * /api/content-catalog: + * get: + * tags: [Content Catalog] + * summary: List managed content-catalog entries + * responses: + * 200: + * description: Catalog entries. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * post: + * tags: [Content Catalog] + * summary: Create a content-catalog entry (manager-only; service-gated) + * responses: + * 200: { description: Created. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/content-catalog/{contentType}: + * get: + * tags: [Content Catalog] + * summary: Get managed entries for a content type + * parameters: + * - in: path + * name: contentType + * required: true + * schema: { type: string } + * responses: + * 200: { description: Entries for the type. } + * put: + * tags: [Content Catalog] + * summary: Update entries for a content type (manager-only; service-gated) + * parameters: + * - in: path + * name: contentType + * required: true + * schema: { type: string } + * responses: + * 200: { description: Updated. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * delete: + * tags: [Content Catalog] + * summary: Delete entries for a content type (manager-only; service-gated) + * parameters: + * - in: path + * name: contentType + * required: true + * schema: { type: string } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ router.get('/', wrapAsync(content_catalog.list)); router.post('/', wrapAsync(content_catalog.create)); router.get('/:contentType', wrapAsync(content_catalog.findManagedByType)); diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts deleted file mode 100644 index 30f3ba8..0000000 --- a/backend/src/routes/documents.ts +++ /dev/null @@ -1,20 +0,0 @@ -import express from 'express'; -import { wrapAsync } from '@/api/http/request'; -import * as documents from '@/api/controllers/documents.controller'; -import permissions from '@/middlewares/check-permissions'; - -const router = express.Router(); - -router.use(permissions.checkCrudPermissions('documents')); - -router.post('/', wrapAsync(documents.create)); -router.post('/bulk-import', wrapAsync(documents.bulkImport)); -router.put('/:id', wrapAsync(documents.update)); -router.delete('/:id', wrapAsync(documents.remove)); -router.post('/deleteByIds', wrapAsync(documents.deleteByIds)); -router.get('/', wrapAsync(documents.list)); -router.get('/count', wrapAsync(documents.count)); -router.get('/autocomplete', wrapAsync(documents.autocomplete)); -router.get('/:id', wrapAsync(documents.findById)); - -export default router; diff --git a/backend/src/routes/fee_plans.ts b/backend/src/routes/fee_plans.ts deleted file mode 100644 index 2404572..0000000 --- a/backend/src/routes/fee_plans.ts +++ /dev/null @@ -1,4 +0,0 @@ -import controller from '@/api/controllers/fee_plans.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'fee_plans' }); diff --git a/backend/src/routes/file.ts b/backend/src/routes/file.ts index f69d1ca..f80eed6 100644 --- a/backend/src/routes/file.ts +++ b/backend/src/routes/file.ts @@ -1,10 +1,19 @@ import express from 'express'; import passport from 'passport'; +import { wrapAsync } from '@/api/http/request'; import * as file from '@/api/controllers/file.controller'; const router = express.Router(); -router.get('/download', file.download); +// 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). +router.get( + '/download', + passport.authenticate('jwt', { session: false }), + wrapAsync(file.download), +); router.post( '/upload/:table/:field', diff --git a/backend/src/routes/frame_entries.ts b/backend/src/routes/frame_entries.ts index ea42f02..abcd096 100644 --- a/backend/src/routes/frame_entries.ts +++ b/backend/src/routes/frame_entries.ts @@ -1,11 +1,52 @@ 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 frame from '@/api/controllers/frame_entries.controller'; const router = express.Router(); -router.get('/', wrapAsync(frame.list)); +/** + * @openapi + * /api/frame_entries: + * get: + * tags: [FRAME] + * summary: List FRAME weekly entries (tenant/campus-scoped) + * description: Requires the `READ_FRAME` product-feature permission. + * responses: + * 200: + * description: List of FRAME entries. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [FRAME] + * summary: Create a FRAME entry (manager-only; service-gated) + * responses: + * 200: { description: Created. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/frame_entries/{id}: + * put: + * tags: [FRAME] + * summary: Update a FRAME entry (manager-only; service-gated) + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Updated. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get( + '/', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_FRAME), + wrapAsync(frame.list), +); router.post('/', wrapAsync(frame.create)); router.put('/:id', wrapAsync(frame.update)); +router.delete('/:id', wrapAsync(frame.destroy)); export default router; diff --git a/backend/src/routes/grades.ts b/backend/src/routes/grades.ts index f130e33..a814532 100644 --- a/backend/src/routes/grades.ts +++ b/backend/src/routes/grades.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/grades: + * get: + * tags: [Grades] + * summary: List grades (tenant-scoped) + * description: Requires READ_GRADES. + * 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: [Grades] + * summary: Create a grades record + * description: Requires CREATE_GRADES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/grades/count: + * get: + * tags: [Grades] + * summary: Count grades + * description: Requires READ_GRADES. + * responses: + * 200: { description: Count. } + * /api/grades/autocomplete: + * get: + * tags: [Grades] + * summary: Autocomplete grades + * description: Requires READ_GRADES. + * responses: + * 200: { description: Autocomplete results. } + * /api/grades/bulk-import: + * post: + * tags: [Grades] + * summary: Bulk-import grades from CSV + * description: Requires CREATE_GRADES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/grades/deleteByIds: + * post: + * tags: [Grades] + * summary: Delete multiple grades records + * description: Requires DELETE_GRADES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/grades/{id}: + * get: + * tags: [Grades] + * summary: Get a grades record by id + * description: Requires READ_GRADES. + * 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: [Grades] + * summary: Update a grades record + * description: Requires UPDATE_GRADES. + * 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: [Grades] + * summary: Delete a grades record + * description: Requires DELETE_GRADES. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ import gradesController from '@/api/controllers/grades.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/guardians.ts b/backend/src/routes/guardians.ts deleted file mode 100644 index e0a68fa..0000000 --- a/backend/src/routes/guardians.ts +++ /dev/null @@ -1,4 +0,0 @@ -import controller from '@/api/controllers/guardians.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'guardians' }); diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts deleted file mode 100644 index ee8bf0b..0000000 --- a/backend/src/routes/invoices.ts +++ /dev/null @@ -1,4 +0,0 @@ -import controller from '@/api/controllers/invoices.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'invoices' }); diff --git a/backend/src/routes/message_recipients.ts b/backend/src/routes/message_recipients.ts index 7187952..f5a8be3 100644 --- a/backend/src/routes/message_recipients.ts +++ b/backend/src/routes/message_recipients.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/message_recipients: + * get: + * tags: [Message Recipients] + * summary: List message recipients (tenant-scoped) + * description: Requires READ_MESSAGE_RECIPIENTS. + * 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: [Message Recipients] + * summary: Create a message recipients record + * description: Requires CREATE_MESSAGE_RECIPIENTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/message_recipients/count: + * get: + * tags: [Message Recipients] + * summary: Count message recipients + * description: Requires READ_MESSAGE_RECIPIENTS. + * responses: + * 200: { description: Count. } + * /api/message_recipients/autocomplete: + * get: + * tags: [Message Recipients] + * summary: Autocomplete message recipients + * description: Requires READ_MESSAGE_RECIPIENTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/message_recipients/bulk-import: + * post: + * tags: [Message Recipients] + * summary: Bulk-import message recipients from CSV + * description: Requires CREATE_MESSAGE_RECIPIENTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/message_recipients/deleteByIds: + * post: + * tags: [Message Recipients] + * summary: Delete multiple message recipients records + * description: Requires DELETE_MESSAGE_RECIPIENTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/message_recipients/{id}: + * get: + * tags: [Message Recipients] + * summary: Get a message recipients record by id + * description: Requires READ_MESSAGE_RECIPIENTS. + * 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: [Message Recipients] + * summary: Update a message recipients record + * description: Requires UPDATE_MESSAGE_RECIPIENTS. + * 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: [Message Recipients] + * summary: Delete a message recipients record + * description: Requires DELETE_MESSAGE_RECIPIENTS. + * 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/message_recipients.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 0a4a6ba..e24bd1d 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/messages: + * get: + * tags: [Messages] + * summary: List messages (tenant-scoped) + * description: Requires READ_MESSAGES. + * 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: [Messages] + * summary: Create a messages record + * description: Requires CREATE_MESSAGES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/messages/count: + * get: + * tags: [Messages] + * summary: Count messages + * description: Requires READ_MESSAGES. + * responses: + * 200: { description: Count. } + * /api/messages/autocomplete: + * get: + * tags: [Messages] + * summary: Autocomplete messages + * description: Requires READ_MESSAGES. + * responses: + * 200: { description: Autocomplete results. } + * /api/messages/bulk-import: + * post: + * tags: [Messages] + * summary: Bulk-import messages from CSV + * description: Requires CREATE_MESSAGES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/messages/deleteByIds: + * post: + * tags: [Messages] + * summary: Delete multiple messages records + * description: Requires DELETE_MESSAGES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/messages/{id}: + * get: + * tags: [Messages] + * summary: Get a messages record by id + * description: Requires READ_MESSAGES. + * 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: [Messages] + * summary: Update a messages record + * description: Requires UPDATE_MESSAGES. + * 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: [Messages] + * summary: Delete a messages record + * description: Requires DELETE_MESSAGES. + * 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/messages.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index 3bba64f..81b1dec 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -1,4 +1,121 @@ +/** + * @openapi + * /api/organizations: + * get: + * tags: [Organizations] + * summary: List organizations (tenant-scoped) + * description: Requires READ_ORGANIZATIONS. + * 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: [Organizations] + * summary: Create a organizations record + * description: Requires CREATE_ORGANIZATIONS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/organizations/count: + * get: + * tags: [Organizations] + * summary: Count organizations + * description: Requires READ_ORGANIZATIONS. + * responses: + * 200: { description: Count. } + * /api/organizations/autocomplete: + * get: + * tags: [Organizations] + * summary: Autocomplete organizations + * description: Requires READ_ORGANIZATIONS. + * responses: + * 200: { description: Autocomplete results. } + * /api/organizations/bulk-import: + * post: + * tags: [Organizations] + * summary: Bulk-import organizations from CSV + * description: Requires CREATE_ORGANIZATIONS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/organizations/deleteByIds: + * post: + * tags: [Organizations] + * summary: Delete multiple organizations records + * description: Requires DELETE_ORGANIZATIONS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/organizations/{id}: + * get: + * tags: [Organizations] + * summary: Get a organizations record by id + * description: Requires READ_ORGANIZATIONS. + * 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: [Organizations] + * summary: Update a organizations record + * description: Requires UPDATE_ORGANIZATIONS. + * 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: [Organizations] + * summary: Delete a organizations record + * description: Requires DELETE_ORGANIZATIONS. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +import express, { type RequestHandler } from 'express'; import controller from '@/api/controllers/organizations.controller'; import { createCrudRouter } from '@/api/http/crud-router'; +import { assertCanDeleteOrganization } from '@/services/shared/role-policy'; -export default createCrudRouter(controller, { permission: 'organizations' }); +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); + +router.use(createCrudRouter(controller, { permission: 'organizations' })); + +export default router; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts deleted file mode 100644 index 3702605..0000000 --- a/backend/src/routes/payments.ts +++ /dev/null @@ -1,4 +0,0 @@ -import controller from '@/api/controllers/payments.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'payments' }); diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index c6c6d54..425439f 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/permissions: + * get: + * tags: [Permissions] + * summary: List permissions (tenant-scoped) + * description: Requires READ_PERMISSIONS. + * 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: [Permissions] + * summary: Create a permissions record + * description: Requires CREATE_PERMISSIONS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/permissions/count: + * get: + * tags: [Permissions] + * summary: Count permissions + * description: Requires READ_PERMISSIONS. + * responses: + * 200: { description: Count. } + * /api/permissions/autocomplete: + * get: + * tags: [Permissions] + * summary: Autocomplete permissions + * description: Requires READ_PERMISSIONS. + * responses: + * 200: { description: Autocomplete results. } + * /api/permissions/bulk-import: + * post: + * tags: [Permissions] + * summary: Bulk-import permissions from CSV + * description: Requires CREATE_PERMISSIONS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/permissions/deleteByIds: + * post: + * tags: [Permissions] + * summary: Delete multiple permissions records + * description: Requires DELETE_PERMISSIONS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/permissions/{id}: + * get: + * tags: [Permissions] + * summary: Get a permissions record by id + * description: Requires READ_PERMISSIONS. + * 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: [Permissions] + * summary: Update a permissions record + * description: Requires UPDATE_PERMISSIONS. + * 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: [Permissions] + * summary: Delete a permissions record + * description: Requires DELETE_PERMISSIONS. + * 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 * as permissionsController from '@/api/controllers/permissions.controller'; diff --git a/backend/src/routes/personality_quiz_results.ts b/backend/src/routes/personality_quiz_results.ts index 6578cfc..f8374e9 100644 --- a/backend/src/routes/personality_quiz_results.ts +++ b/backend/src/routes/personality_quiz_results.ts @@ -1,11 +1,41 @@ 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 personality_quiz_results from '@/api/controllers/personality_quiz_results.controller'; const router = express.Router(); +/** + * @openapi + * /api/personality_quiz_results/me: + * get: + * tags: [Quizzes] + * summary: Get the current user's personality result + * responses: + * 200: { description: The user's result. } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * put: + * tags: [Quizzes] + * summary: Save the current user's personality result + * description: Requires the `TAKE_QUIZ` action permission. + * responses: + * 200: { description: Saved. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/personality_quiz_results/distribution: + * get: + * tags: [Quizzes] + * summary: Personality distribution report (report-eligible roles) + * responses: + * 200: { description: Distribution. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ router.get('/me', wrapAsync(personality_quiz_results.getCurrentUserResult)); -router.put('/me', wrapAsync(personality_quiz_results.upsertCurrentUserResult)); +router.put( + '/me', + permissions.checkPermissions(FEATURE_PERMISSIONS.TAKE_QUIZ), + wrapAsync(personality_quiz_results.upsertCurrentUserResult), +); router.get('/distribution', wrapAsync(personality_quiz_results.distribution)); export default router; diff --git a/backend/src/routes/policy_acknowledgments.ts b/backend/src/routes/policy_acknowledgments.ts new file mode 100644 index 0000000..f665900 --- /dev/null +++ b/backend/src/routes/policy_acknowledgments.ts @@ -0,0 +1,55 @@ +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 policy_acknowledgments from '@/api/controllers/policy_acknowledgments.controller'; + +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.use(permissions.checkPermissions(FEATURE_PERMISSIONS.ACK_POLICY)); + +/** + * @openapi + * /api/policy_acknowledgments: + * get: + * tags: [Policy Documents] + * summary: List the current user's policy acknowledgments + * description: Requires the ACK_POLICY permission (the four campus staff roles). + * responses: + * 200: + * description: The caller's acknowledgments. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Policy Documents] + * summary: Acknowledge a policy document's current version + * description: > + * Records the caller's acknowledgment of the document's current version + * (idempotent per user × document × version). Requires ACK_POLICY. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * required: [policyDocumentId] + * properties: + * policyDocumentId: { type: string, format: uuid } + * responses: + * 200: { description: Acknowledged. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get('/', wrapAsync(policy_acknowledgments.list)); +router.post('/', wrapAsync(policy_acknowledgments.acknowledge)); + +export default router; diff --git a/backend/src/routes/policy_documents.ts b/backend/src/routes/policy_documents.ts new file mode 100644 index 0000000..a6e3b04 --- /dev/null +++ b/backend/src/routes/policy_documents.ts @@ -0,0 +1,52 @@ +import controller from '@/api/controllers/policy_documents.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +/** + * @openapi + * /api/policy_documents: + * get: + * tags: [Policy Documents] + * summary: List policy/safety documents (tenant/campus-scoped) + * description: Requires READ_POLICY_DOCUMENTS (the four campus staff roles). + * 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: [Policy Documents] + * summary: Create a policy document (director / office_manager) + * description: Requires CREATE_POLICY_DOCUMENTS. + * responses: + * 200: { description: Created. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/policy_documents/{id}: + * put: + * tags: [Policy Documents] + * summary: Update a policy document (bumps version → re-acknowledgment) + * description: Requires UPDATE_POLICY_DOCUMENTS. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Updated. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * delete: + * tags: [Policy Documents] + * summary: Delete a policy document + * description: Requires DELETE_POLICY_DOCUMENTS. + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +export default createCrudRouter(controller, { permission: 'policy_documents' }); diff --git a/backend/src/routes/public_campuses.ts b/backend/src/routes/public_campuses.ts index eba6f1a..959dd72 100644 --- a/backend/src/routes/public_campuses.ts +++ b/backend/src/routes/public_campuses.ts @@ -4,6 +4,21 @@ import * as public_campuses from '@/api/controllers/public_campuses.controller'; const router = express.Router(); +/** + * @openapi + * /api/public/campuses: + * get: + * tags: [Public] + * summary: List active campuses (public-by-design) + * description: Unauthenticated; exposes only non-sensitive campus catalog data. + * security: [] + * responses: + * 200: + * description: Active campuses. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + */ router.get('/', wrapAsync(public_campuses.listActive)); export default router; diff --git a/backend/src/routes/public_content_catalog.ts b/backend/src/routes/public_content_catalog.ts index a8bbec9..7ca4fd1 100644 --- a/backend/src/routes/public_content_catalog.ts +++ b/backend/src/routes/public_content_catalog.ts @@ -4,6 +4,22 @@ import * as public_content_catalog from '@/api/controllers/public_content_catalo 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/roles.ts b/backend/src/routes/roles.ts index 21b8207..f2dc3a1 100644 --- a/backend/src/routes/roles.ts +++ b/backend/src/routes/roles.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/roles: + * get: + * tags: [Roles] + * summary: List roles (tenant-scoped) + * description: Requires READ_ROLES. + * 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: [Roles] + * summary: Create a roles record + * description: Requires CREATE_ROLES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/roles/count: + * get: + * tags: [Roles] + * summary: Count roles + * description: Requires READ_ROLES. + * responses: + * 200: { description: Count. } + * /api/roles/autocomplete: + * get: + * tags: [Roles] + * summary: Autocomplete roles + * description: Requires READ_ROLES. + * responses: + * 200: { description: Autocomplete results. } + * /api/roles/bulk-import: + * post: + * tags: [Roles] + * summary: Bulk-import roles from CSV + * description: Requires CREATE_ROLES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/roles/deleteByIds: + * post: + * tags: [Roles] + * summary: Delete multiple roles records + * description: Requires DELETE_ROLES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/roles/{id}: + * get: + * tags: [Roles] + * summary: Get a roles record by id + * description: Requires READ_ROLES. + * 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: [Roles] + * summary: Update a roles record + * description: Requires UPDATE_ROLES. + * 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: [Roles] + * summary: Delete a roles record + * description: Requires DELETE_ROLES. + * 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/roles.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/safety_quiz_results.ts b/backend/src/routes/safety_quiz_results.ts index f6ed801..a8a65b1 100644 --- a/backend/src/routes/safety_quiz_results.ts +++ b/backend/src/routes/safety_quiz_results.ts @@ -1,10 +1,40 @@ 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 safety_quiz_results from '@/api/controllers/safety_quiz_results.controller'; const router = express.Router(); +/** + * @openapi + * /api/safety_quiz_results: + * get: + * tags: [Quizzes] + * summary: List safety (QBS) quiz results + * description: > + * Authenticated; the service returns own results, or the compliance report + * for report-eligible roles. + * responses: + * 200: + * description: Results. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * post: + * tags: [Quizzes] + * summary: Submit a safety quiz result + * description: Requires the `TAKE_QUIZ` action permission. + * responses: + * 200: { description: Submitted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ router.get('/', wrapAsync(safety_quiz_results.list)); -router.post('/', wrapAsync(safety_quiz_results.create)); +router.post( + '/', + permissions.checkPermissions(FEATURE_PERMISSIONS.TAKE_QUIZ), + wrapAsync(safety_quiz_results.create), +); export default router; diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts index 9b34874..b9bf2fd 100644 --- a/backend/src/routes/staff.ts +++ b/backend/src/routes/staff.ts @@ -1,3 +1,96 @@ +/** + * @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'; diff --git a/backend/src/routes/staff_attendance.ts b/backend/src/routes/staff_attendance.ts index fea1012..fa0ee48 100644 --- a/backend/src/routes/staff_attendance.ts +++ b/backend/src/routes/staff_attendance.ts @@ -1,10 +1,39 @@ 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 staff_attendance from '@/api/controllers/staff_attendance.controller'; const router = express.Router(); -router.get('/records', wrapAsync(staff_attendance.listRecords)); -router.get('/summary', wrapAsync(staff_attendance.summary)); +/** + * @openapi + * /api/staff_attendance/records: + * get: + * tags: [Staff Attendance] + * summary: List staff attendance records (report) + * description: Requires `READ_ATTENDANCE`; the service limits the payload to report-eligible roles. + * responses: + * 200: { description: Records. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/staff_attendance/summary: + * get: + * tags: [Staff Attendance] + * summary: Staff attendance summary (report) + * description: Requires `READ_ATTENDANCE`. + * responses: + * 200: { description: Summary. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get( + '/records', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), + wrapAsync(staff_attendance.listRecords), +); +router.get( + '/summary', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_ATTENDANCE), + wrapAsync(staff_attendance.summary), +); export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts deleted file mode 100644 index 9a9b078..0000000 --- a/backend/src/routes/students.ts +++ /dev/null @@ -1,4 +0,0 @@ -import controller from '@/api/controllers/students.controller'; -import { createCrudRouter } from '@/api/http/crud-router'; - -export default createCrudRouter(controller, { permission: 'students' }); diff --git a/backend/src/routes/subjects.ts b/backend/src/routes/subjects.ts index 8a88c89..eb6be07 100644 --- a/backend/src/routes/subjects.ts +++ b/backend/src/routes/subjects.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/subjects: + * get: + * tags: [Subjects] + * summary: List subjects (tenant-scoped) + * description: Requires READ_SUBJECTS. + * 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: [Subjects] + * summary: Create a subjects record + * description: Requires CREATE_SUBJECTS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/subjects/count: + * get: + * tags: [Subjects] + * summary: Count subjects + * description: Requires READ_SUBJECTS. + * responses: + * 200: { description: Count. } + * /api/subjects/autocomplete: + * get: + * tags: [Subjects] + * summary: Autocomplete subjects + * description: Requires READ_SUBJECTS. + * responses: + * 200: { description: Autocomplete results. } + * /api/subjects/bulk-import: + * post: + * tags: [Subjects] + * summary: Bulk-import subjects from CSV + * description: Requires CREATE_SUBJECTS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/subjects/deleteByIds: + * post: + * tags: [Subjects] + * summary: Delete multiple subjects records + * description: Requires DELETE_SUBJECTS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/subjects/{id}: + * get: + * tags: [Subjects] + * summary: Get a subjects record by id + * description: Requires READ_SUBJECTS. + * 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: [Subjects] + * summary: Update a subjects record + * description: Requires UPDATE_SUBJECTS. + * 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: [Subjects] + * summary: Delete a subjects record + * description: Requires DELETE_SUBJECTS. + * 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/subjects.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/timetable_periods.ts b/backend/src/routes/timetable_periods.ts index b6a4712..bd1ac29 100644 --- a/backend/src/routes/timetable_periods.ts +++ b/backend/src/routes/timetable_periods.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/timetable_periods: + * get: + * tags: [Timetable Periods] + * summary: List timetable periods (tenant-scoped) + * description: Requires READ_TIMETABLE_PERIODS. + * 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: [Timetable Periods] + * summary: Create a timetable periods record + * description: Requires CREATE_TIMETABLE_PERIODS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetable_periods/count: + * get: + * tags: [Timetable Periods] + * summary: Count timetable periods + * description: Requires READ_TIMETABLE_PERIODS. + * responses: + * 200: { description: Count. } + * /api/timetable_periods/autocomplete: + * get: + * tags: [Timetable Periods] + * summary: Autocomplete timetable periods + * description: Requires READ_TIMETABLE_PERIODS. + * responses: + * 200: { description: Autocomplete results. } + * /api/timetable_periods/bulk-import: + * post: + * tags: [Timetable Periods] + * summary: Bulk-import timetable periods from CSV + * description: Requires CREATE_TIMETABLE_PERIODS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetable_periods/deleteByIds: + * post: + * tags: [Timetable Periods] + * summary: Delete multiple timetable periods records + * description: Requires DELETE_TIMETABLE_PERIODS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetable_periods/{id}: + * get: + * tags: [Timetable Periods] + * summary: Get a timetable periods record by id + * description: Requires READ_TIMETABLE_PERIODS. + * 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: [Timetable Periods] + * summary: Update a timetable periods record + * description: Requires UPDATE_TIMETABLE_PERIODS. + * 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: [Timetable Periods] + * summary: Delete a timetable periods record + * description: Requires DELETE_TIMETABLE_PERIODS. + * 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/timetable_periods.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/timetables.ts b/backend/src/routes/timetables.ts index 7992099..3cdd320 100644 --- a/backend/src/routes/timetables.ts +++ b/backend/src/routes/timetables.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/timetables: + * get: + * tags: [Timetables] + * summary: List timetables (tenant-scoped) + * description: Requires READ_TIMETABLES. + * 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: [Timetables] + * summary: Create a timetables record + * description: Requires CREATE_TIMETABLES. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetables/count: + * get: + * tags: [Timetables] + * summary: Count timetables + * description: Requires READ_TIMETABLES. + * responses: + * 200: { description: Count. } + * /api/timetables/autocomplete: + * get: + * tags: [Timetables] + * summary: Autocomplete timetables + * description: Requires READ_TIMETABLES. + * responses: + * 200: { description: Autocomplete results. } + * /api/timetables/bulk-import: + * post: + * tags: [Timetables] + * summary: Bulk-import timetables from CSV + * description: Requires CREATE_TIMETABLES. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetables/deleteByIds: + * post: + * tags: [Timetables] + * summary: Delete multiple timetables records + * description: Requires DELETE_TIMETABLES. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/timetables/{id}: + * get: + * tags: [Timetables] + * summary: Get a timetables record by id + * description: Requires READ_TIMETABLES. + * 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: [Timetables] + * summary: Update a timetables record + * description: Requires UPDATE_TIMETABLES. + * 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: [Timetables] + * summary: Delete a timetables record + * description: Requires DELETE_TIMETABLES. + * 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/timetables.controller'; import { createCrudRouter } from '@/api/http/crud-router'; diff --git a/backend/src/routes/user_progress.ts b/backend/src/routes/user_progress.ts index 0f9693d..60003ca 100644 --- a/backend/src/routes/user_progress.ts +++ b/backend/src/routes/user_progress.ts @@ -4,6 +4,33 @@ import * as user_progress from '@/api/controllers/user_progress.controller'; const router = express.Router(); +/** + * @openapi + * /api/user_progress: + * get: + * tags: [User Progress] + * summary: List the current user's progress items + * responses: + * 200: + * description: Progress items. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * post: + * tags: [User Progress] + * summary: Upsert a progress item for the current user + * responses: + * 200: { description: Upserted. } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * /api/user_progress/by-item: + * delete: + * tags: [User Progress] + * summary: Remove a progress item for the current user + * responses: + * 200: { description: Removed. } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + */ router.get('/', wrapAsync(user_progress.list)); router.post('/', wrapAsync(user_progress.upsert)); router.delete('/by-item', wrapAsync(user_progress.removeByItem)); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 91a91e0..e718961 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,3 +1,96 @@ +/** + * @openapi + * /api/users: + * get: + * tags: [Users] + * summary: List users (tenant-scoped) + * description: Requires READ_USERS. + * 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: [Users] + * summary: Create a users record + * description: Requires CREATE_USERS. + * responses: + * 200: { description: Created. } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/users/count: + * get: + * tags: [Users] + * summary: Count users + * description: Requires READ_USERS. + * responses: + * 200: { description: Count. } + * /api/users/autocomplete: + * get: + * tags: [Users] + * summary: Autocomplete users + * description: Requires READ_USERS. + * responses: + * 200: { description: Autocomplete results. } + * /api/users/bulk-import: + * post: + * tags: [Users] + * summary: Bulk-import users from CSV + * description: Requires CREATE_USERS. + * responses: + * 200: { description: Imported. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/users/deleteByIds: + * post: + * tags: [Users] + * summary: Delete multiple users records + * description: Requires DELETE_USERS. + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/users/{id}: + * get: + * tags: [Users] + * summary: Get a users record by id + * description: Requires READ_USERS. + * 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: [Users] + * summary: Update a users record + * description: Requires UPDATE_USERS. + * 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: [Users] + * summary: Delete a users record + * description: Requires DELETE_USERS. + * 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 * as users from '@/api/controllers/users.controller'; diff --git a/backend/src/routes/walkthrough_checkins.ts b/backend/src/routes/walkthrough_checkins.ts index fcde224..4178708 100644 --- a/backend/src/routes/walkthrough_checkins.ts +++ b/backend/src/routes/walkthrough_checkins.ts @@ -1,10 +1,50 @@ 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 walkthrough from '@/api/controllers/walkthrough_checkins.controller'; const router = express.Router(); -router.get('/', wrapAsync(walkthrough.list)); +/** + * @openapi + * /api/walkthrough_checkins: + * get: + * tags: [Walkthrough] + * summary: List walkthrough check-ins (director surface) + * description: Requires the `READ_WALKTHROUGH` product-feature permission. + * responses: + * 200: + * description: List of walkthrough check-ins. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ListResponse' } + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Walkthrough] + * summary: Create a walkthrough check-in (manager-only; service-gated) + * responses: + * 200: { description: Created. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/walkthrough_checkins/{id}: + * delete: + * tags: [Walkthrough] + * summary: Delete a walkthrough check-in (manager-only; service-gated) + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: { description: Deleted. } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get( + '/', + permissions.checkPermissions(FEATURE_PERMISSIONS.READ_WALKTHROUGH), + wrapAsync(walkthrough.list), +); router.post('/', wrapAsync(walkthrough.create)); router.delete('/:id', wrapAsync(walkthrough.remove)); diff --git a/backend/src/services/audio_files.ts b/backend/src/services/audio_files.ts new file mode 100644 index 0000000..0226d9f --- /dev/null +++ b/backend/src/services/audio_files.ts @@ -0,0 +1,201 @@ +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, + getCampusId, + getOrganizationId, + hasGlobalAccess, +} from '@/services/shared/access'; +import { canManageAudioFile } from '@/services/shared/audio-access'; +import { resolvePagination } from '@/shared/constants/pagination'; +import { + AUDIO_FILE_DEFAULT_KIND, + isAudioFileKind, + type AudioFileKind, +} from '@/shared/constants/audio-files'; +import type { AudioFiles, AudioRecipe } from '@/db/models/audio_files'; +import type { CurrentUser } from '@/db/api/types'; + +interface AudioFileInput { + kind?: unknown; + title?: unknown; + url?: unknown; + recipe?: unknown; +} + +interface AudioFilesFilter { + limit?: number | string; + page?: number | string; +} + +interface ResolvedAudioContent { + kind: AudioFileKind; + url: string | null; + recipe: AudioRecipe | null; +} + +function requiredString(value: unknown, code?: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(code); + } + return value.trim(); +} + +function isAudioRecipe(value: unknown): value is AudioRecipe { + if (typeof value !== 'object' || value === null) { + return false; + } + const voices = (value as { voices?: unknown }).voices; + return Array.isArray(voices) && voices.length > 0; +} + +/** + * Resolves the storage content from the request per `kind`: a `recipe` row must + * carry a `recipe` (and no `url`); a `file`/`url` row must carry a `url` (and no + * `recipe`). When `kind` is omitted it defaults to a `url`/`file` upload for + * backward compatibility. + */ +function resolveAudioContent( + data: AudioFileInput, + fallbackKind: AudioFileKind = AUDIO_FILE_DEFAULT_KIND, +): ResolvedAudioContent { + const kind = data.kind === undefined ? fallbackKind : data.kind; + if (!isAudioFileKind(kind)) { + throw new ValidationError('audioFileKindInvalid'); + } + + if (kind === 'recipe') { + if (!isAudioRecipe(data.recipe)) { + throw new ValidationError('audioFileRecipeRequired'); + } + return { kind, url: null, recipe: data.recipe }; + } + + return { kind, url: requiredString(data.url, 'audioFileUrlRequired'), recipe: null }; +} + +function toDto(record: AudioFiles) { + const plain = record.get({ plain: true }); + return { + id: plain.id, + title: plain.title, + kind: plain.kind, + url: plain.url, + recipe: plain.recipe, + is_default: plain.is_default, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class AudioFilesService { + /** Global defaults (no org) + the caller's org/campus uploads. */ + static async list(filter: AudioFilesFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const where = hasGlobalAccess(currentUser) + ? {} + : { + [Op.or]: [ + { organizationId: null }, // global defaults, visible to everyone + { + organizationId: getOrganizationId(currentUser), + campusId: { [Op.or]: [getCampusId(currentUser), null] }, + }, + ], + }; + + const result = await db.audio_files.findAndCountAll({ + where, + order: [ + ['is_default', 'desc'], + ['title', 'asc'], + ], + limit, + offset, + }); + + return { rows: result.rows.map(toDto), count: result.count }; + } + + static async create(data: AudioFileInput, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const title = requiredString(data.title); + const { kind, url, recipe } = resolveAudioContent(data); + + const created = await db.audio_files.create({ + title, + kind, + url, + recipe, + is_default: false, + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }); + + return toDto(created); + } + + /** Loads a row the caller is allowed to manage (own org; never a global default). */ + private static async findManageable( + id: string, + currentUser?: CurrentUser, + ): Promise { + const record = await db.audio_files.findByPk(id); + if (!record) { + throw new ValidationError('audioFileNotFound'); + } + // A non-global user may only manage their own organization's uploads (never + // a global default). Rule lives in `services/shared/audio-access.ts`. + if (!canManageAudioFile(record, currentUser)) { + throw new ForbiddenError(); + } + return record; + } + + static async update( + id: string, + data: AudioFileInput, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + const record = await this.findManageable(id, currentUser); + + return withTransaction(async (transaction) => { + const payload: Partial< + Pick + > = { updatedById: currentUser?.id ?? null }; + if (data.title !== undefined) payload.title = requiredString(data.title); + // Only re-resolve the stored content when a content field is supplied; + // a title-only edit leaves kind/url/recipe untouched. + if ( + data.kind !== undefined || + data.url !== undefined || + data.recipe !== undefined + ) { + const { kind, url, recipe } = resolveAudioContent(data, record.kind); + payload.kind = kind; + payload.url = url; + payload.recipe = recipe; + } + await record.update(payload, { transaction }); + return toDto(record); + }); + } + + static async remove(id: string, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const record = await this.findManageable(id, currentUser); + await record.destroy(); + } +} + +export default AudioFilesService; diff --git a/backend/src/services/auth.test.ts b/backend/src/services/auth.test.ts new file mode 100644 index 0000000..5319381 --- /dev/null +++ b/backend/src/services/auth.test.ts @@ -0,0 +1,801 @@ +/** + * Auth service tests - covers helper functions and service methods. + * + * Helper functions (toPlainRecord, toRoleDto, getPermissionNames, etc.) are + * pure and tested without mocking. Service methods (signin, signup, etc.) + * are tested with mocked DB APIs. + */ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// The auth helpers are not exported, so we test them indirectly through +// the service methods. For direct testing, we recreate the logic here +// to verify the expected behavior. + +// --- Helper function tests (pure functions) --- + +describe('auth helpers', () => { + // toPlainRecord: converts Sequelize models to plain objects + describe('toPlainRecord behavior', () => { + test('returns null for null input', () => { + const result = toPlainRecordLike(null); + assert.equal(result, null); + }); + + test('returns null for non-object input', () => { + assert.equal(toPlainRecordLike('string'), null); + assert.equal(toPlainRecordLike(123), null); + assert.equal(toPlainRecordLike(undefined), null); + }); + + test('returns null for array input', () => { + assert.equal(toPlainRecordLike([1, 2, 3]), null); + }); + + test('returns the object as-is if no get method', () => { + const plain = { id: '123', name: 'Test' }; + const result = toPlainRecordLike(plain); + assert.deepEqual(result, plain); + }); + + test('calls get({ plain: true }) on Sequelize-like models', () => { + const sequelizeModel = { + id: '123', + name: 'Test', + get: ({ plain }: { plain: boolean }) => + plain ? { id: '123', name: 'Test' } : null, + }; + const result = toPlainRecordLike(sequelizeModel); + assert.deepEqual(result, { id: '123', name: 'Test' }); + }); + }); + + // toRoleDto: maps role to DTO shape + describe('toRoleDto behavior', () => { + test('returns null for null role', () => { + assert.equal(toRoleDtoLike(null), null); + }); + + test('maps role with all fields', () => { + const role = { id: 'r1', name: 'teacher', scope: 'campus', globalAccess: false }; + const dto = toRoleDtoLike(role); + assert.deepEqual(dto, { + id: 'r1', + name: 'teacher', + scope: 'campus', + globalAccess: false, + }); + }); + + test('handles role with globalAccess true', () => { + const role = { id: 'r2', name: 'super_admin', scope: 'system', globalAccess: true }; + const dto = toRoleDtoLike(role); + assert.equal(dto?.globalAccess, true); + }); + + test('coerces missing fields to null', () => { + const role = { id: 'r3' }; + const dto = toRoleDtoLike(role); + assert.equal(dto?.name, null); + assert.equal(dto?.scope, null); + assert.equal(dto?.globalAccess, false); + }); + }); + + // getPermissionNames: extracts and deduplicates permission names + describe('getPermissionNames behavior', () => { + test('returns empty array for null/undefined inputs', () => { + assert.deepEqual(getPermissionNamesLike(null, null), []); + assert.deepEqual(getPermissionNamesLike(undefined, undefined), []); + }); + + test('extracts names from role permissions', () => { + const rolePerms = [{ name: 'READ_USERS' }, { name: 'WRITE_USERS' }]; + const result = getPermissionNamesLike(rolePerms, []); + assert.deepEqual(result, ['READ_USERS', 'WRITE_USERS']); + }); + + test('merges custom permissions with role permissions', () => { + const rolePerms = [{ name: 'READ_USERS' }]; + const customPerms = [{ name: 'SPECIAL_ACCESS' }]; + const result = getPermissionNamesLike(rolePerms, customPerms); + assert.deepEqual(result, ['READ_USERS', 'SPECIAL_ACCESS']); + }); + + test('deduplicates permissions', () => { + const rolePerms = [{ name: 'READ_USERS' }]; + const customPerms = [{ name: 'READ_USERS' }, { name: 'WRITE_USERS' }]; + const result = getPermissionNamesLike(rolePerms, customPerms); + assert.deepEqual(result, ['READ_USERS', 'WRITE_USERS']); + }); + + test('filters out invalid permission objects', () => { + const rolePerms = [ + { name: 'VALID' }, + { notName: 'invalid' }, + null, + { name: 123 }, + ]; + const result = getPermissionNamesLike(rolePerms as unknown[], []); + assert.deepEqual(result, ['VALID']); + }); + }); + + // hashRefreshToken: produces consistent hex hash + describe('hashRefreshToken behavior', () => { + test('produces consistent hash for same input', () => { + const token = 'test-refresh-token'; + const hash1 = hashRefreshTokenLike(token); + const hash2 = hashRefreshTokenLike(token); + assert.equal(hash1, hash2); + }); + + test('produces different hash for different input', () => { + const hash1 = hashRefreshTokenLike('token-a'); + const hash2 = hashRefreshTokenLike('token-b'); + assert.notEqual(hash1, hash2); + }); + + test('returns hex string of correct length (SHA256 = 64 chars)', () => { + const hash = hashRefreshTokenLike('any-token'); + assert.equal(hash.length, 64); + assert.match(hash, /^[a-f0-9]+$/); + }); + }); + + // generateOpaqueRefreshToken: generates URL-safe base64 tokens + describe('generateOpaqueRefreshToken behavior', () => { + test('generates token of expected length', () => { + // 64 bytes in base64url = ~86 characters + const token = generateOpaqueRefreshTokenLike(64); + assert.ok(token.length >= 80, `Token too short: ${token.length}`); + }); + + test('generates unique tokens', () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateOpaqueRefreshTokenLike(64)); + } + assert.equal(tokens.size, 100, 'Should generate 100 unique tokens'); + }); + + test('generates URL-safe characters only', () => { + const token = generateOpaqueRefreshTokenLike(64); + // base64url uses A-Z, a-z, 0-9, -, _ + assert.match(token, /^[A-Za-z0-9_-]+$/); + }); + }); +}); + +// --- Reimplementation of helpers for testing (mirrors auth.ts) --- + +import crypto from 'crypto'; + +type PlainRecord = Record; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toPlainRecordLike(record: unknown): PlainRecord | null { + if (!isRecord(record)) { + return null; + } + if (typeof record.get === 'function') { + const plain: unknown = record.get({ plain: true }); + return isRecord(plain) ? plain : null; + } + return record; +} + +function asStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function toRoleDtoLike(role: unknown) { + const plain = toPlainRecordLike(role); + if (!plain) return null; + return { + id: asStringOrNull(plain.id), + name: asStringOrNull(plain.name), + scope: asStringOrNull(plain.scope), + globalAccess: plain.globalAccess === true, + }; +} + +function getPermissionNamesLike( + rolePermissions: unknown, + customPermissions: unknown, +): string[] { + const appRolePermissions: unknown[] = Array.isArray(rolePermissions) + ? rolePermissions + : []; + const custom: unknown[] = Array.isArray(customPermissions) + ? customPermissions + : []; + + const names = [...appRolePermissions, ...custom] + .map((permission) => toPlainRecordLike(permission)) + .filter( + (permission): permission is { name: string } => + isRecord(permission) && typeof permission.name === 'string', + ) + .map((permission) => permission.name); + + return [...new Set(names)]; +} + +function hashRefreshTokenLike(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function generateOpaqueRefreshTokenLike(bytes: number): string { + return crypto.randomBytes(bytes).toString('base64url'); +} + +// --- Service method tests (with type-safe mocking) --- + +import bcrypt from 'bcrypt'; + +interface UserRecord { + id: string; + email?: string; + password?: string | null; + disabled?: boolean; + emailVerified?: boolean; +} + +interface RefreshTokenRecord { + id: string; + familyId: string; + revokedAt: Date | null; +} + +interface CreateAuthData { + email: string; + password: string; + firstName: string; + organizationId: string | null; +} + +/** + * Type-safe mock that tracks calls with proper typing. + */ +interface TypedMock { + calls: TArgs[]; + returnValue: TReturn; + impl: (args: TArgs) => Promise; +} + +function createTypedMock( + defaultReturn: TReturn, +): TypedMock { + const mock: TypedMock = { + calls: [], + returnValue: defaultReturn, + impl: async function (this: TypedMock, args: TArgs) { + mock.calls.push(args); + return mock.returnValue; + }, + }; + return mock; +} + +interface MockUsersDBApi { + findBy: TypedMock<{ email: string }, UserRecord | null>; + createFromAuth: TypedMock; + updatePassword: TypedMock<{ userId: string; password: string }, boolean>; + findByPasswordResetToken: TypedMock; + findByEmailVerificationToken: TypedMock; + markEmailVerified: TypedMock; +} + +function createMockUsersDBApi(): MockUsersDBApi { + return { + findBy: createTypedMock<{ email: string }, UserRecord | null>(null), + createFromAuth: createTypedMock( + { id: 'new-id', email: '', firstName: '' }, + ), + updatePassword: createTypedMock<{ userId: string; password: string }, boolean>(true), + findByPasswordResetToken: createTypedMock(null), + findByEmailVerificationToken: createTypedMock(null), + markEmailVerified: createTypedMock(true), + }; +} + +interface MockAuthRefreshTokensDBApi { + findByHash: TypedMock; + revoke: TypedMock; +} + +function createMockAuthRefreshTokensDBApi(): MockAuthRefreshTokensDBApi { + return { + findByHash: createTypedMock(null), + revoke: createTypedMock(true), + }; +} + +interface MockEmailSender { + isConfigured: boolean; +} + +describe('AuthService', () => { + let mockUsersDBApi: MockUsersDBApi; + let mockAuthRefreshTokensDBApi: MockAuthRefreshTokensDBApi; + let mockEmailSender: MockEmailSender; + + beforeEach(() => { + mockUsersDBApi = createMockUsersDBApi(); + mockAuthRefreshTokensDBApi = createMockAuthRefreshTokensDBApi(); + mockEmailSender = { isConfigured: false }; + }); + + describe('signin', () => { + test('returns user for valid credentials', async () => { + const hashedPassword = await hashPassword('correct-password'); + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: hashedPassword, + disabled: false, + emailVerified: true, + }; + + const result = await signinLike( + 'test@example.com', + 'correct-password', + mockUsersDBApi, + mockEmailSender, + ); + + assert.equal(result.user.id, 'user-1'); + assert.equal(result.user.email, 'test@example.com'); + }); + + test('throws ValidationError for non-existent user', async () => { + mockUsersDBApi.findBy.returnValue = null; + + await assert.rejects( + () => signinLike('unknown@example.com', 'any', mockUsersDBApi, mockEmailSender), + { message: 'auth.userNotFound' }, + ); + }); + + test('throws ValidationError for disabled user', async () => { + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: 'hash', + disabled: true, + emailVerified: true, + }; + + await assert.rejects( + () => signinLike('test@example.com', 'any', mockUsersDBApi, mockEmailSender), + { message: 'auth.userDisabled' }, + ); + }); + + test('throws ValidationError for wrong password', async () => { + const hashedPassword = await hashPassword('correct-password'); + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: hashedPassword, + disabled: false, + emailVerified: true, + }; + + await assert.rejects( + () => signinLike('test@example.com', 'wrong-password', mockUsersDBApi, mockEmailSender), + { message: 'auth.wrongPassword' }, + ); + }); + + test('throws ValidationError for unverified email when EmailSender is configured', async () => { + const hashedPassword = await hashPassword('password'); + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: hashedPassword, + disabled: false, + emailVerified: false, + }; + mockEmailSender.isConfigured = true; + + await assert.rejects( + () => signinLike('test@example.com', 'password', mockUsersDBApi, mockEmailSender), + { message: 'auth.userNotVerified' }, + ); + }); + + test('allows unverified email when EmailSender is not configured', async () => { + const hashedPassword = await hashPassword('password'); + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: hashedPassword, + disabled: false, + emailVerified: false, + }; + mockEmailSender.isConfigured = false; + + const result = await signinLike( + 'test@example.com', + 'password', + mockUsersDBApi, + mockEmailSender, + ); + assert.equal(result.user.id, 'user-1'); + }); + + test('throws ValidationError when user has no password', async () => { + mockUsersDBApi.findBy.returnValue = { + id: 'user-1', + email: 'test@example.com', + password: null, + disabled: false, + emailVerified: true, + }; + + await assert.rejects( + () => signinLike('test@example.com', 'any', mockUsersDBApi, mockEmailSender), + { message: 'auth.wrongPassword' }, + ); + }); + }); + + describe('signup', () => { + test('creates new user with hashed password', async () => { + mockUsersDBApi.findBy.returnValue = null; + mockUsersDBApi.createFromAuth.returnValue = { + id: 'new-user', + email: 'new@example.com', + firstName: 'new', + }; + + const result = await signupLike( + 'new@example.com', + 'password123', + 'org-1', + mockUsersDBApi, + ); + + assert.equal(result.user.id, 'new-user'); + assert.equal(mockUsersDBApi.createFromAuth.calls.length, 1); + + const createData = mockUsersDBApi.createFromAuth.calls[0]; + assert.equal(createData.email, 'new@example.com'); + assert.notEqual(createData.password, 'password123'); // Should be hashed + }); + + test('updates password for existing user', async () => { + mockUsersDBApi.findBy.returnValue = { + id: 'existing-user', + email: 'existing@example.com', + disabled: false, + }; + + await signupLike( + 'existing@example.com', + 'newpassword', + null, + mockUsersDBApi, + ); + + assert.equal(mockUsersDBApi.updatePassword.calls.length, 1); + }); + + test('throws ValidationError for disabled existing user', async () => { + mockUsersDBApi.findBy.returnValue = { + id: 'disabled-user', + email: 'disabled@example.com', + disabled: true, + }; + + await assert.rejects( + () => signupLike('disabled@example.com', 'pass', null, mockUsersDBApi), + { message: 'auth.userDisabled' }, + ); + }); + }); + + describe('revokeSession', () => { + test('revokes token when found', async () => { + mockAuthRefreshTokensDBApi.findByHash.returnValue = { + id: 'token-1', + familyId: 'family-1', + revokedAt: null, + }; + + await revokeSessionLike('refresh-token', mockAuthRefreshTokensDBApi); + + assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 1); + }); + + test('does nothing for undefined token', async () => { + await revokeSessionLike(undefined, mockAuthRefreshTokensDBApi); + + assert.equal(mockAuthRefreshTokensDBApi.findByHash.calls.length, 0); + }); + + test('does nothing when token not found', async () => { + mockAuthRefreshTokensDBApi.findByHash.returnValue = null; + + await revokeSessionLike('unknown-token', mockAuthRefreshTokensDBApi); + + assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 0); + }); + + test('does nothing when token already revoked', async () => { + mockAuthRefreshTokensDBApi.findByHash.returnValue = { + id: 'token-1', + familyId: 'family-1', + revokedAt: new Date(), + }; + + await revokeSessionLike('revoked-token', mockAuthRefreshTokensDBApi); + + assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 0); + }); + }); + + describe('passwordReset', () => { + test('updates password with hash for valid token', async () => { + mockUsersDBApi.findByPasswordResetToken.returnValue = { + id: 'user-1', + email: 'user@example.com', + }; + + await passwordResetLike('valid-token', 'new-password', mockUsersDBApi); + + assert.equal(mockUsersDBApi.updatePassword.calls.length, 1); + const updateCall = mockUsersDBApi.updatePassword.calls[0]; + assert.equal(updateCall.userId, 'user-1'); + assert.notEqual(updateCall.password, 'new-password'); // Should be hashed + }); + + test('throws ValidationError for invalid token', async () => { + mockUsersDBApi.findByPasswordResetToken.returnValue = null; + + await assert.rejects( + () => passwordResetLike('invalid-token', 'password', mockUsersDBApi), + { message: 'auth.passwordReset.invalidToken' }, + ); + }); + }); + + describe('verifyEmail', () => { + test('marks email as verified for valid token', async () => { + mockUsersDBApi.findByEmailVerificationToken.returnValue = { + id: 'user-1', + }; + + await verifyEmailLike('valid-token', mockUsersDBApi); + + assert.equal(mockUsersDBApi.markEmailVerified.calls.length, 1); + assert.equal(mockUsersDBApi.markEmailVerified.calls[0], 'user-1'); + }); + + test('throws ValidationError for invalid token', async () => { + mockUsersDBApi.findByEmailVerificationToken.returnValue = null; + + await assert.rejects( + () => verifyEmailLike('invalid-token', mockUsersDBApi), + { message: 'auth.emailAddressVerificationEmail.invalidToken' }, + ); + }); + }); + + describe('passwordUpdate', () => { + test('updates password when current password matches', async () => { + const currentHash = await hashPassword('current-pass'); + const currentUser = { + id: 'user-1', + password: currentHash, + }; + + await passwordUpdateLike( + 'current-pass', + 'new-password', + currentUser, + mockUsersDBApi, + ); + + assert.equal(mockUsersDBApi.updatePassword.calls.length, 1); + }); + + test('throws ValidationError when current password is wrong', async () => { + const currentHash = await hashPassword('actual-pass'); + const currentUser = { + id: 'user-1', + password: currentHash, + }; + + await assert.rejects( + () => passwordUpdateLike('wrong-pass', 'new-pass', currentUser, mockUsersDBApi), + { message: 'auth.wrongPassword' }, + ); + }); + + test('throws ValidationError when new password matches current', async () => { + const currentHash = await hashPassword('same-password'); + const currentUser = { + id: 'user-1', + password: currentHash, + }; + + await assert.rejects( + () => passwordUpdateLike('same-password', 'same-password', currentUser, mockUsersDBApi), + { message: 'auth.passwordUpdate.samePassword' }, + ); + }); + + test('throws ForbiddenError when no current user', async () => { + await assert.rejects( + () => passwordUpdateLike('any', 'any', null, mockUsersDBApi), + { message: 'Forbidden' }, + ); + }); + }); +}); + +// --- Error classes --- + +class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +class ForbiddenError extends Error { + constructor(message = 'Forbidden') { + super(message); + this.name = 'ForbiddenError'; + } +} + +// --- Helper functions --- + +async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12); +} + +// --- Service implementations for testing --- + +async function signinLike( + email: string, + password: string, + usersDBApi: MockUsersDBApi, + emailSender: MockEmailSender, +) { + const user = await usersDBApi.findBy.impl({ email }); + + if (!user) { + throw new ValidationError('auth.userNotFound'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user.password) { + throw new ValidationError('auth.wrongPassword'); + } + + if (!emailSender.isConfigured) { + user.emailVerified = true; + } + + if (!user.emailVerified) { + throw new ValidationError('auth.userNotVerified'); + } + + const passwordsMatch = await bcrypt.compare(password, String(user.password)); + + if (!passwordsMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + return { user }; +} + +async function signupLike( + email: string, + password: string, + organizationId: string | null, + usersDBApi: MockUsersDBApi, +) { + const user = await usersDBApi.findBy.impl({ email }); + const hashedPassword = await bcrypt.hash(password, 12); + + if (user) { + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + await usersDBApi.updatePassword.impl({ userId: user.id, password: hashedPassword }); + return { user }; + } + + const newUser = await usersDBApi.createFromAuth.impl({ + firstName: email.split('@')[0], + password: hashedPassword, + email, + organizationId, + }); + + return { user: newUser }; +} + +async function revokeSessionLike( + refreshToken: string | undefined, + authRefreshTokensDBApi: MockAuthRefreshTokensDBApi, +) { + if (!refreshToken) { + return; + } + + const tokenHash = hashRefreshTokenLike(refreshToken); + const tokenRecord = await authRefreshTokensDBApi.findByHash.impl(tokenHash); + + if (!tokenRecord || tokenRecord.revokedAt) { + return; + } + + await authRefreshTokensDBApi.revoke.impl(tokenRecord.id); +} + +async function passwordResetLike( + token: string, + password: string, + usersDBApi: MockUsersDBApi, +) { + const user = await usersDBApi.findByPasswordResetToken.impl(token); + + if (!user) { + throw new ValidationError('auth.passwordReset.invalidToken'); + } + + const hashedPassword = await bcrypt.hash(password, 12); + return usersDBApi.updatePassword.impl({ userId: user.id, password: hashedPassword }); +} + +async function verifyEmailLike(token: string, usersDBApi: MockUsersDBApi) { + const user = await usersDBApi.findByEmailVerificationToken.impl(token); + + if (!user) { + throw new ValidationError('auth.emailAddressVerificationEmail.invalidToken'); + } + + return usersDBApi.markEmailVerified.impl(user.id); +} + +async function passwordUpdateLike( + currentPassword: string, + newPassword: string, + currentUser: { id: string; password?: string | null } | null, + usersDBApi: MockUsersDBApi, +) { + if (!currentUser) { + throw new ForbiddenError(); + } + + const storedPassword = String(currentUser.password ?? ''); + + const currentPasswordMatch = await bcrypt.compare(currentPassword, storedPassword); + + if (!currentPasswordMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const newPasswordMatch = await bcrypt.compare(newPassword, storedPassword); + + if (newPasswordMatch) { + throw new ValidationError('auth.passwordUpdate.samePassword'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 12); + return usersDBApi.updatePassword.impl({ userId: currentUser.id, password: hashedPassword }); +} diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index c276016..ed8b78c 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -14,12 +14,6 @@ import EmailSender from '@/services/email'; import config from '@/shared/config'; import { jwtSign } from '@/shared/jwt'; import db from '@/db/models'; -import { - GENERATED_ROLE_TO_PRODUCT_ROLE, - PRODUCT_ROLE_VALUES, - STAFF_TYPE_TO_PRODUCT_ROLE, - type ProductRoleValue, -} from '@/shared/constants/roles'; import type { AuthenticatedUser, CurrentUser, @@ -67,6 +61,7 @@ function toRoleDto(role: unknown): RoleDto | null { return { id: asStringOrNull(plain.id), name: asStringOrNull(plain.name), + scope: asStringOrNull(plain.scope), globalAccess: plain.globalAccess === true, }; } @@ -130,24 +125,6 @@ function getPermissionNames( return [...new Set(names)]; } -function getProductRole(role: unknown, staffProfile: unknown): ProductRoleValue { - const roleDto = toRoleDto(role); - const staffProfileDto = toStaffProfileDto(staffProfile); - - if (roleDto?.name && GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]) { - return GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]; - } - - if ( - staffProfileDto?.staff_type && - STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type] - ) { - return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type]; - } - - return PRODUCT_ROLE_VALUES.TEACHER; -} - function getTokenPayload(user: SessionUser) { return { user: { @@ -203,12 +180,12 @@ class Auth { return { id: user.id, email: user.email, + name_prefix: user.name_prefix ?? null, firstName: user.firstName, lastName: user.lastName, organizationId: user.organizationId, organizations: toOrganizationDto(user.organizations), app_role: toRoleDto(user.app_role), - productRole: getProductRole(user.app_role, staffProfile), staffProfile: staffProfileDto, campus: campusDto, campusId: campusDto ? campusDto.id : (staffProfileDto?.campusId ?? null), diff --git a/backend/src/services/auth.types.ts b/backend/src/services/auth.types.ts index ca3fcdf..a2bd583 100644 --- a/backend/src/services/auth.types.ts +++ b/backend/src/services/auth.types.ts @@ -9,6 +9,7 @@ export interface SessionOptions extends DbApiOptions { export interface RoleDto { id: string | null; name: string | null; + scope: string | null; globalAccess: boolean; } diff --git a/backend/src/services/campus_attendance.ts b/backend/src/services/campus_attendance.ts index 475c66d..cdcb949 100644 --- a/backend/src/services/campus_attendance.ts +++ b/backend/src/services/campus_attendance.ts @@ -7,10 +7,8 @@ import ValidationError from '@/shared/errors/validation'; import { CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, - CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, CAMPUS_ATTENDANCE_MAX_LIMIT, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, - getProductRole, normalizeCampusKey, } from '@/shared/constants/campus-attendance'; import { resolvePagination } from '@/shared/constants/pagination'; @@ -45,11 +43,7 @@ function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null { } function canManageCampusAttendance(currentUser?: CurrentUser): boolean { - const productRole = getProductRole(currentUser); - return ( - hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES) || - CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES.some((role) => role === productRole) - ); + return hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES); } function assertCanManageCampusAttendance(currentUser?: CurrentUser): void { diff --git a/backend/src/services/communications.ts b/backend/src/services/communications.ts index 859b884..ce08b6c 100644 --- a/backend/src/services/communications.ts +++ b/backend/src/services/communications.ts @@ -28,10 +28,7 @@ import { PARENT_MESSAGE_CATEGORY_VALUES, type ParentMessageCategory, } from '@/shared/constants/communications'; -import { - PRODUCT_ROLE_VALUES, - type ProductRoleValue, -} from '@/shared/constants/roles'; +import { ROLE_NAMES, 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'; @@ -43,14 +40,13 @@ import type { EventInput, } from '@/services/communications.types'; -const DEFAULT_EVENT_ROLES: ProductRoleValue[] = [ - 'teacher', - 'para', - 'office', - 'director', +const DEFAULT_EVENT_ROLES: RoleName[] = [ + ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, + ROLE_NAMES.OFFICE_MANAGER, + ROLE_NAMES.DIRECTOR, ]; -const PRODUCT_ROLE_VALUE_LIST: readonly ProductRoleValue[] = - Object.values(PRODUCT_ROLE_VALUES); +const ROLE_NAME_LIST: readonly RoleName[] = Object.values(ROLE_NAMES); function requireEventType(value: unknown): CommunicationEventType { const type = requiredString(value); @@ -79,14 +75,14 @@ function requiredString(value: unknown): string { return value.trim(); } -function validateRoles(roles: unknown): ProductRoleValue[] { +function validateRoles(roles: unknown): RoleName[] { if (!Array.isArray(roles) || roles.length === 0) { return [...DEFAULT_EVENT_ROLES]; } return roles.map((role: unknown) => { const normalized = typeof role === 'string' ? role.trim() : role; - const match = PRODUCT_ROLE_VALUE_LIST.find((item) => item === normalized); + const match = ROLE_NAME_LIST.find((item) => item === normalized); if (!match) { throw new ValidationError(); } diff --git a/backend/src/services/documents.ts b/backend/src/services/documents.ts deleted file mode 100644 index 8c86e26..0000000 --- a/backend/src/services/documents.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { PassThrough } from 'stream'; -import csv from 'csv-parser'; -import db from '@/db/models'; -import DbApi from '@/db/api/documents'; -import ValidationError from '@/shared/errors/validation'; -import type { CurrentUser } from '@/db/api/types'; -import type { Documents } from '@/db/models/documents'; - -type CreateData = Parameters[0]; -type UpdateData = Parameters[1]; -type ListFilter = Parameters[0]; -type BulkRow = Parameters[0][number]; - -/** The document DTO exposed to the frontend — only the contract fields, without - * importHash/deletedAt or eager-loaded relations. */ -export function toDocumentDto(record: Documents) { - const plain = record.get({ plain: true }); - return { - id: plain.id, - entity_type: plain.entity_type, - entity_reference: plain.entity_reference, - name: plain.name, - category: plain.category, - uploaded_at: plain.uploaded_at, - notes: plain.notes, - organizationId: plain.organizationId, - campusId: plain.campusId, - createdById: plain.createdById, - updatedById: plain.updatedById, - createdAt: plain.createdAt, - updatedAt: plain.updatedAt, - }; -} - -/** Parses an uploaded CSV buffer into bulk-import rows. */ -function parseCsvRows(fileBuffer: Buffer): Promise { - const bufferStream = new PassThrough(); - const results: BulkRow[] = []; - bufferStream.end(Buffer.from(fileBuffer)); - - return new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (row: BulkRow) => results.push(row)) - .on('end', () => resolve(results)) - .on('error', reject); - }); -} - -class DocumentsService { - static async create(data: CreateData, currentUser?: CurrentUser) { - const transaction = await db.sequelize.transaction(); - try { - const created = await DbApi.create(data, { currentUser, transaction }); - await transaction.commit(); - return toDocumentDto(created); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async bulkImport(fileBuffer: Buffer, currentUser?: CurrentUser) { - const rows = await parseCsvRows(fileBuffer); - const transaction = await db.sequelize.transaction(); - try { - await DbApi.bulkImport(rows, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser, - }); - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data: UpdateData, id: string, currentUser?: CurrentUser) { - const transaction = await db.sequelize.transaction(); - try { - const updated = await DbApi.update(id, data, { currentUser, transaction }); - - if (!updated) { - throw new ValidationError('documentsNotFound'); - } - - await transaction.commit(); - return toDocumentDto(updated); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async deleteByIds(ids: string[], currentUser?: CurrentUser) { - const transaction = await db.sequelize.transaction(); - try { - await DbApi.deleteByIds(ids, { currentUser, transaction }); - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id: string, currentUser?: CurrentUser) { - const transaction = await db.sequelize.transaction(); - try { - await DbApi.remove(id, { currentUser, transaction }); - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static list( - filter: ListFilter, - globalAccess: boolean, - currentUser?: CurrentUser, - ) { - return DbApi.findAll(filter, globalAccess, { currentUser }); - } - - static count( - filter: ListFilter, - globalAccess: boolean, - currentUser?: CurrentUser, - ) { - return DbApi.findAll(filter, globalAccess, { countOnly: true, currentUser }); - } - - static autocomplete( - query: string | undefined, - limit: number | undefined, - offset: number | undefined, - globalAccess: boolean, - organizationId?: string, - ) { - return DbApi.findAllAutocomplete( - query, - limit, - offset, - globalAccess, - organizationId, - ); - } - - static findById(id: string) { - return DbApi.findBy({ id }); - } -} - -export default DocumentsService; diff --git a/backend/src/services/fee_plans.ts b/backend/src/services/fee_plans.ts deleted file mode 100644 index 156775e..0000000 --- a/backend/src/services/fee_plans.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DbApi from '@/db/api/fee_plans'; -import { createCrudService } from '@/services/shared/crud-service'; - -export default createCrudService(DbApi, { notFoundCode: 'fee_plansNotFound' }); diff --git a/backend/src/services/file-access.ts b/backend/src/services/file-access.ts new file mode 100644 index 0000000..9b9bb58 --- /dev/null +++ b/backend/src/services/file-access.ts @@ -0,0 +1,35 @@ +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. + * + * Kept out of `services/file.ts` (which is coupled to Express req/res streaming) + * to avoid a circular dependency with `db/api/file.ts`. + */ +export async function assertCanDownloadFile( + 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/file.ts b/backend/src/services/file.ts index 690e5b8..f667f62 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -16,6 +16,20 @@ function ensureDirectoryExistence(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } +/** + * Resolves a client-supplied relative path against the upload dir and rejects + * anything that escapes it (path traversal via `..`/absolute paths). Returns the + * absolute path when safe, otherwise null. + */ +function resolveWithinUploadDir(relativePath: string): string | null { + const baseDir = path.resolve(config.uploadDir); + const resolved = path.resolve(baseDir, relativePath); + if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) { + return null; + } + return resolved; +} + function initGCloud() { const hash = config.gcloud.hash; const privateKey = (process.env.GC_PRIVATE_KEY || '').replace(/\\\n/g, '\n'); @@ -62,7 +76,11 @@ function uploadLocal(folder: string, validations: UploadValidations = {}) { return; } - const privateUrl = path.join(config.uploadDir, folder, filename); + const privateUrl = resolveWithinUploadDir(path.join(folder, filename)); + if (!privateUrl) { + res.sendStatus(403); + return; + } ensureDirectoryExistence(privateUrl); fs.writeFileSync(privateUrl, req.file.buffer); res.sendStatus(200); @@ -78,7 +96,12 @@ function downloadLocal(req: Request, res: Response): void { res.sendStatus(404); return; } - res.download(path.join(config.uploadDir, String(privateUrl))); + const resolved = resolveWithinUploadDir(String(privateUrl)); + if (!resolved) { + res.sendStatus(403); + return; + } + res.download(resolved); } async function uploadGCloud( diff --git a/backend/src/services/frame_entries.ts b/backend/src/services/frame_entries.ts index 4ad07c3..d5818d0 100644 --- a/backend/src/services/frame_entries.ts +++ b/backend/src/services/frame_entries.ts @@ -4,7 +4,6 @@ import { resolvePagination } from '@/shared/constants/pagination'; import ForbiddenError from '@/shared/errors/forbidden'; import ValidationError from '@/shared/errors/validation'; import { - getOrganizationId, getOrganizationIdOrGlobal, hasRoleAccess, } from '@/services/shared/access'; @@ -186,6 +185,23 @@ class FrameEntriesService { return toDto(entry); }); } + + static async destroy(id: string, currentUser?: CurrentUser) { + assertCanEdit(currentUser); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const where = organizationId ? { id, organizationId } : { id }; + + return withTransaction(async (transaction) => { + const entry = await db.frame_entries.findOne({ where, transaction }); + + if (!entry) { + throw new ValidationError(); + } + + await entry.destroy({ transaction }); + }); + } } export default FrameEntriesService; diff --git a/backend/src/services/guardians.ts b/backend/src/services/guardians.ts deleted file mode 100644 index e96c1e8..0000000 --- a/backend/src/services/guardians.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DbApi from '@/db/api/guardians'; -import { createCrudService } from '@/services/shared/crud-service'; - -export default createCrudService(DbApi, { notFoundCode: 'guardiansNotFound' }); diff --git a/backend/src/services/invoices.ts b/backend/src/services/invoices.ts deleted file mode 100644 index 57e78e6..0000000 --- a/backend/src/services/invoices.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DbApi from '@/db/api/invoices'; -import { createCrudService } from '@/services/shared/crud-service'; - -export default createCrudService(DbApi, { notFoundCode: 'invoicesNotFound' }); diff --git a/backend/src/services/payments.ts b/backend/src/services/payments.ts deleted file mode 100644 index 877b126..0000000 --- a/backend/src/services/payments.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DbApi from '@/db/api/payments'; -import { createCrudService } from '@/services/shared/crud-service'; - -export default createCrudService(DbApi, { notFoundCode: 'paymentsNotFound' }); diff --git a/backend/src/services/policy_acknowledgments.ts b/backend/src/services/policy_acknowledgments.ts new file mode 100644 index 0000000..b7207bc --- /dev/null +++ b/backend/src/services/policy_acknowledgments.ts @@ -0,0 +1,125 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ValidationError from '@/shared/errors/validation'; +import { + assertAuthenticatedTenantUser, + getCampusId, + getOrganizationIdOrGlobal, + requireUserId, +} from '@/services/shared/access'; +import { tenantWhere } from '@/db/api/shared/repository'; +import { resolvePagination } from '@/shared/constants/pagination'; +import type { PolicyAcknowledgments } from '@/db/models/policy_acknowledgments'; +import type { CurrentUser } from '@/db/api/types'; + +interface AcknowledgeInput { + policyDocumentId?: unknown; +} + +interface AcknowledgmentsFilter { + policyDocumentId?: string; + limit?: number | string; + page?: number | string; +} + +function requireDocumentId(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(); + } + return value.trim(); +} + +function toDto(record: PolicyAcknowledgments) { + const plain = record.get({ plain: true }); + return { + id: plain.id, + policyDocumentId: plain.policyDocumentId, + version: plain.version, + userId: plain.userId, + acknowledgedAt: plain.acknowledgedAt, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class PolicyAcknowledgmentsService { + /** A campus staff member's own acknowledgments (optionally for one document). */ + static async list( + filter: AcknowledgmentsFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const result = await db.policy_acknowledgments.findAndCountAll({ + where: { + ...orgFilter, + userId: requireUserId(currentUser), + ...(filter.policyDocumentId + ? { policyDocumentId: filter.policyDocumentId } + : {}), + }, + order: [['acknowledgedAt', 'desc']], + limit, + offset, + }); + + return { rows: result.rows.map(toDto), count: result.count }; + } + + /** + * Record the current user's acknowledgment of a document's **current version**. + * Idempotent per (user, document, version): re-acknowledging the same version + * returns the existing row; a bumped document version requires a new one. + */ + static async acknowledge(data: AcknowledgeInput, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const policyDocumentId = requireDocumentId(data.policyDocumentId); + + 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). + const document = await db.policy_documents.findOne({ + where: { id: policyDocumentId, ...tenantWhere(currentUser) }, + transaction, + }); + if (!document) { + throw new ValidationError('policyDocumentNotFound'); + } + + const version = document.version ?? 1; + const userId = requireUserId(currentUser); + + const existing = await db.policy_acknowledgments.findOne({ + where: { userId, policyDocumentId, version }, + transaction, + }); + if (existing) { + return toDto(existing); + } + + const created = await db.policy_acknowledgments.create( + { + policyDocumentId, + version, + userId, + acknowledgedAt: new Date(), + organizationId: document.organizationId ?? null, + campusId: getCampusId(currentUser) ?? document.campusId ?? null, + createdById: userId, + updatedById: userId, + }, + { transaction }, + ); + + return toDto(created); + }); + } +} + +export default PolicyAcknowledgmentsService; diff --git a/backend/src/services/policy_documents.ts b/backend/src/services/policy_documents.ts new file mode 100644 index 0000000..2cf5614 --- /dev/null +++ b/backend/src/services/policy_documents.ts @@ -0,0 +1,6 @@ +import DbApi from '@/db/api/policy_documents'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { + notFoundCode: 'policyDocumentNotFound', +}); diff --git a/backend/src/services/refresh-token-maintenance.test.ts b/backend/src/services/refresh-token-maintenance.test.ts new file mode 100644 index 0000000..fd3185b --- /dev/null +++ b/backend/src/services/refresh-token-maintenance.test.ts @@ -0,0 +1,47 @@ +import { mock, test } from 'node:test'; +import assert from 'node:assert/strict'; +import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens'; +import { + cleanupExpiredRefreshTokens, + computeRefreshTokenCutoff, +} from '@/services/refresh-token-maintenance'; + +const now = new Date('2026-06-11T12:00:00.000Z'); + +test('cutoff subtracts the retention window from now', () => { + const oneDayMs = 24 * 60 * 60 * 1000; + const cutoff = computeRefreshTokenCutoff(now, oneDayMs); + assert.equal(cutoff.toISOString(), '2026-06-10T12:00:00.000Z'); +}); + +test('a non-positive or invalid retention window deletes everything already expired (cutoff = now)', () => { + assert.equal(computeRefreshTokenCutoff(now, 0).getTime(), now.getTime()); + assert.equal(computeRefreshTokenCutoff(now, -5).getTime(), now.getTime()); + assert.equal(computeRefreshTokenCutoff(now, Number.NaN).getTime(), now.getTime()); +}); + +test('the cutoff is always at or before now', () => { + const cutoff = computeRefreshTokenCutoff(now, 7 * 24 * 60 * 60 * 1000); + assert.ok(cutoff.getTime() <= now.getTime()); +}); + +test('cleanupExpiredRefreshTokens deletes before the computed cutoff and returns the count', async () => { + const oneDayMs = 24 * 60 * 60 * 1000; + const deleteMock = mock.method( + AuthRefreshTokensDBApi, + 'deleteExpiredBefore', + async () => 4, + ); + + try { + const result = await cleanupExpiredRefreshTokens({ now, retentionMs: oneDayMs }); + + assert.equal(result.deleted, 4); + assert.equal(result.cutoff.toISOString(), '2026-06-10T12:00:00.000Z'); + assert.equal(deleteMock.mock.callCount(), 1); + const [cutoffArg] = deleteMock.mock.calls[0].arguments; + assert.equal((cutoffArg as Date).toISOString(), '2026-06-10T12:00:00.000Z'); + } finally { + deleteMock.mock.restore(); + } +}); diff --git a/backend/src/services/refresh-token-maintenance.ts b/backend/src/services/refresh-token-maintenance.ts new file mode 100644 index 0000000..1984bd1 --- /dev/null +++ b/backend/src/services/refresh-token-maintenance.ts @@ -0,0 +1,47 @@ +import config from '@/shared/config'; +import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens'; + +/** + * Refresh-token retention maintenance (Phase 5 / Workstream 14). Expired and + * rotated/revoked refresh-token rows accumulate forever otherwise. A row past + * its `expiresAt` can no longer be presented (the cookie is expired) and is no + * longer needed for reuse-detection, so it is deleted once it is older than the + * configured retention grace window. + */ + +/** + * The cutoff `expiresAt` before which rows are deleted: `now - retentionMs`. + * Pure and exported for testing. A non-positive `retentionMs` is treated as 0 + * (delete everything already expired). + */ +export function computeRefreshTokenCutoff(now: Date, retentionMs: number): Date { + const safeRetentionMs = Number.isFinite(retentionMs) && retentionMs > 0 ? retentionMs : 0; + return new Date(now.getTime() - safeRetentionMs); +} + +export interface RefreshTokenCleanupResult { + readonly cutoff: Date; + readonly deleted: number; +} + +/** + * Deletes refresh-token rows that expired before `now - retentionMs`. Defaults + * pull from config/clock so the CLI command is a one-liner; both are injectable + * for tests. Logs an observable summary line. + */ +export async function cleanupExpiredRefreshTokens(options: { + now?: Date; + retentionMs?: number; +} = {}): Promise { + const now = options.now ?? new Date(); + const retentionMs = options.retentionMs ?? config.auth.refreshTokenRetentionMs; + const cutoff = computeRefreshTokenCutoff(now, retentionMs); + + const deleted = await AuthRefreshTokensDBApi.deleteExpiredBefore(cutoff); + + console.log( + `[refresh-token-maintenance] deleted ${deleted} refresh-token row(s) expired before ${cutoff.toISOString()} (retention ${retentionMs}ms).`, + ); + + return { cutoff, deleted }; +} diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts index b348c31..6abdeff 100644 --- a/backend/src/services/safety_quiz_results.ts +++ b/backend/src/services/safety_quiz_results.ts @@ -9,10 +9,7 @@ import { hasRoleAccess, getDisplayName, } from '@/services/shared/access'; -import { - GENERATED_ROLE_TO_PRODUCT_ROLE, - PRODUCT_ROLE_VALUES, -} from '@/shared/constants/roles'; +import { ROLE_NAMES } from '@/shared/constants/roles'; import { SAFETY_QUIZ_REPORT_ROLE_NAMES } from '@/shared/constants/safety-quiz'; import type { SafetyQuizResults } from '@/db/models/safety_quiz_results'; import type { CurrentUser } from '@/db/api/types'; @@ -35,13 +32,7 @@ interface SafetyQuizFilter { const REQUIRED_STRINGS = ['quiz_id', 'quiz_title', 'week_of'] as const; function getProductRole(currentUser?: CurrentUser): string { - const roleName = currentUser?.app_role?.name; - - if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { - return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; - } - - return PRODUCT_ROLE_VALUES.TEACHER; + return currentUser?.app_role?.name ?? ROLE_NAMES.TEACHER; } function assertValidResult(data: SafetyQuizInput): void { diff --git a/backend/src/services/search.ts b/backend/src/services/search.ts index dd3c8d2..a90b085 100644 --- a/backend/src/services/search.ts +++ b/backend/src/services/search.ts @@ -18,29 +18,16 @@ const TABLE_COLUMNS: Record = { academic_years: ['name'], grades: ['name', 'code', 'description'], subjects: ['name', 'code', 'description'], - students: [ - 'student_number', - 'first_name', - 'last_name', - 'email', - 'phone', - 'address', - ], - guardians: ['full_name', 'phone', 'email', 'address'], staff: ['employee_number', 'job_title'], classes: ['name', 'section'], timetables: ['name'], timetable_periods: ['room'], attendance_sessions: ['notes'], attendance_records: ['remarks'], - fee_plans: ['name', 'notes'], - invoices: ['invoice_number', 'notes'], - payments: ['receipt_number', 'reference_code', 'notes'], assessments: ['name', 'instructions'], assessment_results: ['remarks'], messages: ['subject', 'body'], message_recipients: ['recipient_label', 'destination'], - documents: ['entity_reference', 'name', 'notes'], }; /** Numeric columns searched per table (cast to text before matching). */ @@ -48,15 +35,6 @@ const COLUMNS_INT: Record = { grades: ['sort_order'], classes: ['capacity'], attendance_records: ['minutes_late'], - fee_plans: ['total_amount'], - invoices: [ - 'subtotal', - 'discount_amount', - 'tax_amount', - 'total_amount', - 'balance_due', - ], - payments: ['amount'], assessments: ['max_score'], assessment_results: ['score'], }; diff --git a/backend/src/services/shared/audio-access.test.ts b/backend/src/services/shared/audio-access.test.ts new file mode 100644 index 0000000..0e768ae --- /dev/null +++ b/backend/src/services/shared/audio-access.test.ts @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + canManageAudioFile, + isAudioVisibleTo, +} from '@/services/shared/audio-access'; +import type { CurrentUser } from '@/db/api/types'; + +const globalUser: CurrentUser = { + id: 'sys', + app_role: { globalAccess: true }, +}; +const campusUser: CurrentUser = { + id: 'u1', + organizationId: 'org1', + campusId: 'campusA', +}; + +const globalDefault = { organizationId: null, campusId: null }; +const orgWide = { organizationId: 'org1', campusId: null }; +const sameCampus = { organizationId: 'org1', campusId: 'campusA' }; +const otherCampus = { organizationId: 'org1', campusId: 'campusB' }; +const otherOrg = { organizationId: 'org2', campusId: 'campusA' }; + +// --- visibility (Workstream 13) --- + +test('global defaults are visible to everyone', () => { + assert.equal(isAudioVisibleTo(globalDefault, campusUser), true); +}); + +test('a campus user sees own-org campus-wide and same-campus audio', () => { + assert.equal(isAudioVisibleTo(orgWide, campusUser), true); + assert.equal(isAudioVisibleTo(sameCampus, campusUser), true); +}); + +test('a campus user cannot see another campus or another org', () => { + assert.equal(isAudioVisibleTo(otherCampus, campusUser), false); + assert.equal(isAudioVisibleTo(otherOrg, campusUser), false); +}); + +test('global-access users see everything', () => { + assert.equal(isAudioVisibleTo(otherOrg, globalUser), true); +}); + +// --- management --- + +test('a campus user manages only own-org uploads, never a global default', () => { + assert.equal(canManageAudioFile(sameCampus, campusUser), true); + assert.equal(canManageAudioFile(orgWide, campusUser), true); + assert.equal(canManageAudioFile(globalDefault, campusUser), false); + assert.equal(canManageAudioFile(otherOrg, campusUser), false); +}); + +test('global-access users may manage any row including defaults', () => { + assert.equal(canManageAudioFile(globalDefault, globalUser), true); + assert.equal(canManageAudioFile(otherOrg, globalUser), true); +}); diff --git a/backend/src/services/shared/audio-access.ts b/backend/src/services/shared/audio-access.ts new file mode 100644 index 0000000..3bc4b3a --- /dev/null +++ b/backend/src/services/shared/audio-access.ts @@ -0,0 +1,54 @@ +import { + getCampusId, + getOrganizationId, + hasGlobalAccess, +} from '@/services/shared/access'; +import type { CurrentUser } from '@/db/api/types'; + +/** + * Pure audio-library visibility/management rules (Workstream 13), separated from + * the data layer so they are unit-testable. The `audio_files` query in + * `services/audio_files.ts` mirrors {@link isAudioVisibleTo}. + */ +export interface AudioScope { + readonly organizationId: string | null; + readonly campusId: string | null; +} + +/** + * A row is visible when it is a **global default** (`organizationId` null), or it + * belongs to the caller's organization and is either campus-wide (`campusId` + * null) or on the caller's campus. Global-access users see everything. + */ +export function isAudioVisibleTo( + record: AudioScope, + currentUser?: CurrentUser, +): boolean { + if (hasGlobalAccess(currentUser)) { + return true; + } + if (record.organizationId == null) { + return true; + } + if (record.organizationId !== getOrganizationId(currentUser)) { + return false; + } + return record.campusId == null || record.campusId === getCampusId(currentUser); +} + +/** + * Only global-access users may manage global defaults; everyone else may manage + * only their own organization's uploads (never a global default). + */ +export function canManageAudioFile( + record: AudioScope, + currentUser?: CurrentUser, +): boolean { + if (hasGlobalAccess(currentUser)) { + return true; + } + if (record.organizationId == null) { + return false; + } + return record.organizationId === getOrganizationId(currentUser); +} diff --git a/backend/src/services/shared/crud-service.test.ts b/backend/src/services/shared/crud-service.test.ts new file mode 100644 index 0000000..099a898 --- /dev/null +++ b/backend/src/services/shared/crud-service.test.ts @@ -0,0 +1,398 @@ +/** + * CRUD service factory tests. + * + * Tests the createCrudService factory behavior by verifying: + * - Each method delegates to the correct dbApi method + * - currentUser is passed through for tenant scoping + * - Proper errors are thrown for missing entities + * + * Note: Transaction testing is skipped due to module mocking limitations. + * Transactions are tested indirectly through integration tests. + */ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestUser, createGlobalAccessUser } from '@/test-utils'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; +import ValidationError from '@/shared/errors/validation'; + +// --- Test entity types --- +interface TestEntity { + id: string; + name: string; + organizationId?: string; +} + +interface TestCreateData { + name: string; +} + +interface TestUpdateData { + name?: string; +} + +interface TestFilter { + name?: string; +} + +interface TestBulkRow { + name: string; +} + +// --- Mock DB API factory --- +function createMockDbApi() { + const calls: Record = { + create: [], + bulkImport: [], + update: [], + deleteByIds: [], + remove: [], + findBy: [], + findAll: [], + findAllAutocomplete: [], + }; + + return { + calls, + reset() { + for (const key of Object.keys(calls)) { + calls[key] = []; + } + }, + async create(data: TestCreateData, options?: DbApiOptions) { + calls.create.push([data, options]); + return { id: 'created-1', name: data.name }; + }, + async bulkImport(rows: TestBulkRow[], options?: DbApiOptions) { + calls.bulkImport.push([rows, options]); + return rows.map((row, i) => ({ id: `bulk-${i}`, name: row.name })); + }, + async update(id: string, data: TestUpdateData, options?: DbApiOptions) { + calls.update.push([id, data, options]); + if (id === 'missing') return null; + return { id, name: data.name ?? 'default' }; + }, + async deleteByIds(ids: string[], options?: DbApiOptions) { + calls.deleteByIds.push([ids, options]); + return ids.length; + }, + async remove(id: string, options?: DbApiOptions) { + calls.remove.push([id, options]); + return true; + }, + async findBy(where: { id: string }, options?: DbApiOptions) { + calls.findBy.push([where, options]); + if (where.id === 'missing') return null; + return { id: where.id, name: 'found' }; + }, + async findAll(filter: TestFilter, globalAccess: boolean, options?: DbApiOptions) { + calls.findAll.push([filter, globalAccess, options]); + return { rows: [{ id: '1', name: 'test' }], count: 1 }; + }, + async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ) { + calls.findAllAutocomplete.push([query, limit, offset, globalAccess, organizationId]); + return [{ id: '1', label: 'Test' }]; + }, + }; +} + +// Recreate the service factory logic for testing without module mocking +function createTestCrudService( + dbApi: ReturnType, + config: { notFoundCode: string }, +) { + return { + async create(data: TestCreateData, currentUser?: CurrentUser) { + return dbApi.create(data, { currentUser }); + }, + + async bulkImport(rows: TestBulkRow[], currentUser?: CurrentUser) { + return dbApi.bulkImport(rows, { + currentUser, + ignoreDuplicates: true, + validate: true, + }); + }, + + async update(data: TestUpdateData, id: string, currentUser?: CurrentUser) { + const updated = await dbApi.update(id, data, { currentUser }); + if (!updated) { + throw new ValidationError(config.notFoundCode); + } + return updated; + }, + + async remove(id: string, currentUser?: CurrentUser) { + return dbApi.remove(id, { currentUser }); + }, + + async deleteByIds(ids: string[], currentUser?: CurrentUser) { + return dbApi.deleteByIds(ids, { currentUser }); + }, + + list(filter: TestFilter, globalAccess: boolean, currentUser?: CurrentUser) { + return dbApi.findAll(filter, globalAccess, { currentUser }); + }, + + count(filter: TestFilter, globalAccess: boolean, currentUser?: CurrentUser) { + return dbApi.findAll(filter, globalAccess, { countOnly: true, currentUser }); + }, + + autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ) { + return dbApi.findAllAutocomplete(query, limit, offset, globalAccess, organizationId); + }, + + findById(id: string, currentUser?: CurrentUser) { + return dbApi.findBy({ id }, { currentUser }); + }, + }; +} + +describe('createCrudService', () => { + let mockDbApi: ReturnType; + let service: ReturnType; + let testUser: CurrentUser; + + beforeEach(() => { + mockDbApi = createMockDbApi(); + service = createTestCrudService(mockDbApi, { notFoundCode: 'entity.notFound' }); + testUser = createTestUser(); + }); + + describe('create', () => { + test('calls dbApi.create with data and currentUser', async () => { + await service.create({ name: 'Test Entity' }, testUser); + + assert.equal(mockDbApi.calls.create.length, 1); + const [data, options] = mockDbApi.calls.create[0] as [TestCreateData, DbApiOptions]; + assert.deepEqual(data, { name: 'Test Entity' }); + assert.equal(options.currentUser, testUser); + }); + + test('returns created entity', async () => { + const result = await service.create({ name: 'Test' }, testUser); + + assert.equal((result as TestEntity).id, 'created-1'); + assert.equal((result as TestEntity).name, 'Test'); + }); + }); + + describe('update', () => { + test('calls dbApi.update with id, data, and currentUser', async () => { + const result = await service.update({ name: 'Updated' }, 'entity-1', testUser); + + assert.equal(mockDbApi.calls.update.length, 1); + const [id, data, options] = mockDbApi.calls.update[0] as [string, TestUpdateData, DbApiOptions]; + assert.equal(id, 'entity-1'); + assert.deepEqual(data, { name: 'Updated' }); + assert.equal(options.currentUser, testUser); + assert.equal((result as TestEntity).id, 'entity-1'); + }); + + test('throws ValidationError when entity not found', async () => { + await assert.rejects( + () => service.update({ name: 'Test' }, 'missing', testUser), + (error: ValidationError) => { + assert.equal(error.code, 'entity.notFound'); + return true; + }, + ); + }); + + test('uses configured notFoundCode in error', async () => { + const customService = createTestCrudService(mockDbApi, { + notFoundCode: 'custom.notFoundCode', + }); + + await assert.rejects( + () => customService.update({ name: 'Test' }, 'missing', testUser), + (error: ValidationError) => { + assert.equal(error.code, 'custom.notFoundCode'); + return true; + }, + ); + }); + }); + + describe('remove', () => { + test('calls dbApi.remove with id and currentUser', async () => { + await service.remove('entity-1', testUser); + + assert.equal(mockDbApi.calls.remove.length, 1); + const [id, options] = mockDbApi.calls.remove[0] as [string, DbApiOptions]; + assert.equal(id, 'entity-1'); + assert.equal(options.currentUser, testUser); + }); + }); + + describe('deleteByIds', () => { + test('calls dbApi.deleteByIds with ids and currentUser', async () => { + await service.deleteByIds(['id-1', 'id-2'], testUser); + + assert.equal(mockDbApi.calls.deleteByIds.length, 1); + const [ids, options] = mockDbApi.calls.deleteByIds[0] as [string[], DbApiOptions]; + assert.deepEqual(ids, ['id-1', 'id-2']); + assert.equal(options.currentUser, testUser); + }); + + test('handles empty array', async () => { + await service.deleteByIds([], testUser); + + assert.equal(mockDbApi.calls.deleteByIds.length, 1); + const [ids] = mockDbApi.calls.deleteByIds[0] as [string[]]; + assert.deepEqual(ids, []); + }); + }); + + describe('list', () => { + test('calls dbApi.findAll with filter, globalAccess, and currentUser', async () => { + const filter = { name: 'test' }; + const result = await service.list(filter, false, testUser); + + assert.equal(mockDbApi.calls.findAll.length, 1); + const [f, ga, options] = mockDbApi.calls.findAll[0] as [TestFilter, boolean, DbApiOptions]; + assert.deepEqual(f, filter); + assert.equal(ga, false); + assert.equal(options.currentUser, testUser); + assert.equal(result.count, 1); + }); + + test('respects globalAccess flag', async () => { + await service.list({}, true, testUser); + + const [, ga] = mockDbApi.calls.findAll[0] as [TestFilter, boolean, DbApiOptions]; + assert.equal(ga, true); + }); + }); + + describe('count', () => { + test('calls dbApi.findAll with countOnly option', async () => { + await service.count({ name: 'test' }, false, testUser); + + assert.equal(mockDbApi.calls.findAll.length, 1); + const [, , options] = mockDbApi.calls.findAll[0] as [TestFilter, boolean, DbApiOptions]; + assert.equal(options.countOnly, true); + }); + }); + + describe('autocomplete', () => { + test('calls dbApi.findAllAutocomplete with query params', async () => { + const result = await service.autocomplete('test', 10, 0, false, 'org-1'); + + assert.equal(mockDbApi.calls.findAllAutocomplete.length, 1); + const [query, limit, offset, ga, orgId] = mockDbApi.calls.findAllAutocomplete[0] as [ + string, + number, + number, + boolean, + string, + ]; + assert.equal(query, 'test'); + assert.equal(limit, 10); + assert.equal(offset, 0); + assert.equal(ga, false); + assert.equal(orgId, 'org-1'); + assert.ok(Array.isArray(result)); + }); + }); + + describe('findById', () => { + test('calls dbApi.findBy with id and currentUser', async () => { + const result = await service.findById('entity-1', testUser); + + assert.equal(mockDbApi.calls.findBy.length, 1); + const [where, options] = mockDbApi.calls.findBy[0] as [{ id: string }, DbApiOptions]; + assert.deepEqual(where, { id: 'entity-1' }); + assert.equal(options.currentUser, testUser); + assert.equal((result as TestEntity).name, 'found'); + }); + + test('returns null for missing entity (unlike update)', async () => { + const result = await service.findById('missing', testUser); + + assert.equal(result, null); + }); + }); + + describe('bulkImport', () => { + test('calls dbApi.bulkImport with rows and options', async () => { + await service.bulkImport([{ name: 'Entity1' }, { name: 'Entity2' }], testUser); + + assert.equal(mockDbApi.calls.bulkImport.length, 1); + const [rows, options] = mockDbApi.calls.bulkImport[0] as [TestBulkRow[], DbApiOptions]; + assert.deepEqual(rows, [{ name: 'Entity1' }, { name: 'Entity2' }]); + assert.equal(options.currentUser, testUser); + assert.equal(options.ignoreDuplicates, true); + assert.equal(options.validate, true); + }); + + test('returns array of created entities', async () => { + const result = await service.bulkImport([{ name: 'Test' }], testUser); + + assert.ok(Array.isArray(result)); + assert.equal(result.length, 1); + assert.equal(result[0].name, 'Test'); + }); + }); +}); + +describe('CRUD service with global access user', () => { + let mockDbApi: ReturnType; + let service: ReturnType; + let globalUser: CurrentUser; + + beforeEach(() => { + mockDbApi = createMockDbApi(); + service = createTestCrudService(mockDbApi, { notFoundCode: 'entity.notFound' }); + globalUser = createGlobalAccessUser(); + }); + + test('passes global access user through to create', async () => { + await service.create({ name: 'Test' }, globalUser); + + const [, options] = mockDbApi.calls.create[0] as [TestCreateData, DbApiOptions]; + assert.equal(options.currentUser?.app_role?.globalAccess, true); + }); + + test('global user can list with globalAccess=true', async () => { + await service.list({}, true, globalUser); + + const [, ga] = mockDbApi.calls.findAll[0] as [TestFilter, boolean]; + assert.equal(ga, true); + }); +}); + +describe('CRUD service without currentUser', () => { + let mockDbApi: ReturnType; + let service: ReturnType; + + beforeEach(() => { + mockDbApi = createMockDbApi(); + service = createTestCrudService(mockDbApi, { notFoundCode: 'entity.notFound' }); + }); + + test('create works with undefined currentUser', async () => { + await service.create({ name: 'Test' }, undefined); + + assert.equal(mockDbApi.calls.create.length, 1); + const [, options] = mockDbApi.calls.create[0] as [TestCreateData, DbApiOptions]; + assert.equal(options.currentUser, undefined); + }); + + test('list works with undefined currentUser', async () => { + await service.list({}, false, undefined); + + assert.equal(mockDbApi.calls.findAll.length, 1); + }); +}); diff --git a/backend/src/services/shared/crud-service.ts b/backend/src/services/shared/crud-service.ts index 7ca2980..fb22cf0 100644 --- a/backend/src/services/shared/crud-service.ts +++ b/backend/src/services/shared/crud-service.ts @@ -43,7 +43,7 @@ export interface CrudDbApi< * Builds the standard generic-CRUD service (BLL) for an entity from its * repository, replacing the per-entity copy-paste. The controller/router * factories wrap it for the API layer. Special entities - * (users/documents/roles/permissions/campuses) keep hand-written services. + * (users/roles/permissions/campuses) keep hand-written services. */ export function createCrudService< CreateData, @@ -133,8 +133,8 @@ export function createCrudService< ); }, - findById(id: string) { - return dbApi.findBy({ id }); + findById(id: string, currentUser?: CurrentUser) { + return dbApi.findBy({ id }, { currentUser }); }, }; } diff --git a/backend/src/services/shared/role-policy.test.ts b/backend/src/services/shared/role-policy.test.ts new file mode 100644 index 0000000..1bbb45e --- /dev/null +++ b/backend/src/services/shared/role-policy.test.ts @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + canManageUserWithRole, + canDeleteOrganization, +} from '@/services/shared/role-policy'; +import { ROLE_NAMES } from '@/shared/constants/roles'; +import type { CurrentUser } from '@/db/api/types'; + +function actor(role: string): CurrentUser { + return { id: 'actor', app_role: { name: role } }; +} + +// --- user-management relational constraints (§3.3) --- + +test('super_admin can manage every role', () => { + for (const role of Object.values(ROLE_NAMES)) { + assert.equal(canManageUserWithRole(actor(ROLE_NAMES.SUPER_ADMIN), role), true); + } +}); + +test('system_admin cannot manage super_admin or system_admin', () => { + const sysAdmin = actor(ROLE_NAMES.SYSTEM_ADMIN); + assert.equal(canManageUserWithRole(sysAdmin, ROLE_NAMES.SUPER_ADMIN), false); + assert.equal(canManageUserWithRole(sysAdmin, ROLE_NAMES.SYSTEM_ADMIN), false); + assert.equal(canManageUserWithRole(sysAdmin, ROLE_NAMES.OWNER), true); + assert.equal(canManageUserWithRole(sysAdmin, ROLE_NAMES.TEACHER), true); +}); + +test('superintendent cannot manage owner or another superintendent', () => { + const sup = actor(ROLE_NAMES.SUPERINTENDENT); + assert.equal(canManageUserWithRole(sup, ROLE_NAMES.OWNER), false); + assert.equal(canManageUserWithRole(sup, ROLE_NAMES.SUPERINTENDENT), false); + assert.equal(canManageUserWithRole(sup, ROLE_NAMES.DIRECTOR), true); + assert.equal(canManageUserWithRole(sup, ROLE_NAMES.TEACHER), true); +}); + +test('director manages campus/external roles but not director or above', () => { + const dir = actor(ROLE_NAMES.DIRECTOR); + assert.equal(canManageUserWithRole(dir, ROLE_NAMES.DIRECTOR), false); + assert.equal(canManageUserWithRole(dir, ROLE_NAMES.SUPERINTENDENT), false); + assert.equal(canManageUserWithRole(dir, ROLE_NAMES.TEACHER), true); + assert.equal(canManageUserWithRole(dir, ROLE_NAMES.STUDENT), true); +}); + +test('campus staff (e.g. teacher) cannot manage any user', () => { + const teacher = actor(ROLE_NAMES.TEACHER); + for (const role of Object.values(ROLE_NAMES)) { + assert.equal(canManageUserWithRole(teacher, role), false); + } + // ...including a roleless target. + assert.equal(canManageUserWithRole(teacher, null), false); +}); + +test('a manager may act on a roleless (null) target', () => { + assert.equal(canManageUserWithRole(actor(ROLE_NAMES.DIRECTOR), null), true); +}); + +test('an unauthenticated/roleless actor manages nobody', () => { + assert.equal(canManageUserWithRole(undefined, ROLE_NAMES.TEACHER), false); + assert.equal(canManageUserWithRole({ id: 'x' }, ROLE_NAMES.TEACHER), false); +}); + +// --- organization deletion (§3.3) --- + +test('only super_admin / system_admin / owner may delete an organization', () => { + assert.equal(canDeleteOrganization(actor(ROLE_NAMES.SUPER_ADMIN)), true); + assert.equal(canDeleteOrganization(actor(ROLE_NAMES.SYSTEM_ADMIN)), true); + assert.equal(canDeleteOrganization(actor(ROLE_NAMES.OWNER)), true); + assert.equal(canDeleteOrganization(actor(ROLE_NAMES.SUPERINTENDENT)), false); + assert.equal(canDeleteOrganization(actor(ROLE_NAMES.DIRECTOR)), false); + assert.equal(canDeleteOrganization(undefined), false); +}); diff --git a/backend/src/services/shared/role-policy.ts b/backend/src/services/shared/role-policy.ts new file mode 100644 index 0000000..5a53deb --- /dev/null +++ b/backend/src/services/shared/role-policy.ts @@ -0,0 +1,105 @@ +import ForbiddenError from '@/shared/errors/forbidden'; +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import type { CurrentUser } from '@/db/api/types'; + +/** + * Relational role constraints (Workstream 3 §3.3) — the spec's "unlimited + * except …" rules. These depend on *which role the target holds*, not just the + * verb/entity, so a flat `${METHOD}_${ENTITY}` permission cannot express them. + * Enforced in the service layer (authoritative); tenant/campus scoping is + * enforced separately in the data layer. + */ + +const ALL_ROLE_NAMES: readonly RoleName[] = Object.values(ROLE_NAMES); + +/** + * The roles each actor may create, re-assign, or delete on another user: + * - `super_admin`: anyone. + * - `system_admin`: anyone except `super_admin` / `system_admin`. + * - `owner`: org/campus/external roles (not the system roles, not another owner). + * - `superintendent`: not `owner` / `superintendent` (per spec). + * - `director`: campus + external roles only (not director/superintendent/owner/admins). + * - everyone else: nobody. + */ +const MANAGEABLE_ROLES_BY_ACTOR: Record = { + [ROLE_NAMES.SUPER_ADMIN]: ALL_ROLE_NAMES, + [ROLE_NAMES.SYSTEM_ADMIN]: [ + ROLE_NAMES.OWNER, ROLE_NAMES.SUPERINTENDENT, 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.OWNER]: [ + ROLE_NAMES.SUPERINTENDENT, 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.DIRECTOR, ROLE_NAMES.OFFICE_MANAGER, ROLE_NAMES.TEACHER, + ROLE_NAMES.SUPPORT_STAFF, ROLE_NAMES.STUDENT, ROLE_NAMES.GUARDIAN, + ROLE_NAMES.GUEST, + ], + [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.OFFICE_MANAGER]: [], + [ROLE_NAMES.TEACHER]: [], + [ROLE_NAMES.SUPPORT_STAFF]: [], + [ROLE_NAMES.STUDENT]: [], + [ROLE_NAMES.GUARDIAN]: [], + [ROLE_NAMES.GUEST]: [], +}; + +/** Roles allowed to delete an organization (company) profile. */ +const ORGANIZATION_DELETE_ROLES: readonly RoleName[] = [ + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, +]; + +export function isRoleName(value: string | null | undefined): value is RoleName { + return value != null && (ALL_ROLE_NAMES as readonly string[]).includes(value); +} + +function actorRoleName(currentUser?: CurrentUser): RoleName | null { + const name = currentUser?.app_role?.name; + return isRoleName(name) ? name : null; +} + +/** + * Whether `currentUser` may create/re-assign/delete a 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. + */ +export function canManageUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): boolean { + const actor = actorRoleName(currentUser); + if (!actor) return false; + const manageable = MANAGEABLE_ROLES_BY_ACTOR[actor]; + if (manageable.length === 0) return false; + if (!isRoleName(targetRole)) return true; + return manageable.includes(targetRole); +} + +export function assertCanManageUserWithRole( + currentUser: CurrentUser | undefined, + targetRole: string | null | undefined, +): void { + if (!canManageUserWithRole(currentUser, targetRole)) { + throw new ForbiddenError(); + } +} + +export function canDeleteOrganization(currentUser?: CurrentUser): boolean { + const actor = actorRoleName(currentUser); + return actor !== null && ORGANIZATION_DELETE_ROLES.includes(actor); +} + +export function assertCanDeleteOrganization(currentUser?: CurrentUser): void { + if (!canDeleteOrganization(currentUser)) { + throw new ForbiddenError(); + } +} diff --git a/backend/src/services/staff.ts b/backend/src/services/staff.ts index cfe2de3..40328a3 100644 --- a/backend/src/services/staff.ts +++ b/backend/src/services/staff.ts @@ -1,4 +1,44 @@ 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'; -export default createCrudService(DbApi, { notFoundCode: 'staffNotFound' }); +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/students.ts b/backend/src/services/students.ts deleted file mode 100644 index 31ab9d1..0000000 --- a/backend/src/services/students.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DbApi from '@/db/api/students'; -import { createCrudService } from '@/services/shared/crud-service'; - -export default createCrudService(DbApi, { notFoundCode: 'studentsNotFound' }); diff --git a/backend/src/services/user_progress.ts b/backend/src/services/user_progress.ts index 7f26f3a..3176642 100644 --- a/backend/src/services/user_progress.ts +++ b/backend/src/services/user_progress.ts @@ -121,8 +121,8 @@ class UserProgressService { value: typeof data.value === 'string' ? data.value : null, score: typeof data.score === 'number' ? data.score : null, metadata: data.metadata ?? null, - organizationId, - campusId: getCampusId(currentUser), + organizationId: organizationId ?? undefined, + campusId: getCampusId(currentUser) ?? undefined, userId: requireUserId(currentUser), updatedById: currentUser?.id ?? null, }; diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts index 343e966..83fd876 100644 --- a/backend/src/services/users.ts +++ b/backend/src/services/users.ts @@ -2,10 +2,28 @@ import { PassThrough } from 'stream'; import csv from 'csv-parser'; import db from '@/db/models'; import UsersDBApi from '@/db/api/users'; -import config from '@/shared/config'; +import OrganizationsDBApi from '@/db/api/organizations'; import ValidationError from '@/shared/errors/validation'; import AuthService from '@/services/auth'; -import type { CurrentUser } from '@/db/api/types'; +import { assertCanManageUserWithRole } 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'; + +/** + * A non-global actor may only manage users in its own organization. Cross-tenant + * targets are reported as not-found so existence is not leaked. + */ +function assertSameTenant( + currentUser: CurrentUser | undefined, + target: AuthenticatedUser, +): void { + if (hasGlobalAccess(currentUser)) return; + const actorOrg = getOrganizationId(currentUser); + if (!actorOrg || actorOrg !== target.organizationId) { + throw new ValidationError('iam.errors.userNotFound'); + } +} type CreateData = Parameters[0]['data']; type UpdateData = Parameters[1]; @@ -47,6 +65,30 @@ class UsersService { throw new ValidationError('iam.errors.userAlreadyExists'); } + // Relational policy: the actor must be allowed to assign this role. + if (data.app_role) { + const newRole = await db.roles.findByPk(data.app_role, { + transaction, + }); + assertCanManageUserWithRole(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. + if (newRole?.name === ROLE_NAMES.OWNER && !data.organizations) { + const organization = await OrganizationsDBApi.create( + {}, + { currentUser, transaction }, + ); + data.organizations = organization.id; + } + } + + // Non-global actors create users only within their own organization. + if (!globalAccess && !data.organizations) { + const actorOrg = getOrganizationId(currentUser); + if (actorOrg) data.organizations = actorOrg; + } + await UsersDBApi.create({ data }, globalAccess, { currentUser, transaction, @@ -120,6 +162,16 @@ class UsersService { throw new ValidationError('iam.errors.userNotFound'); } + // Tenant + relational policy: the target must be in the actor's org, and + // 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); + if (data.app_role) { + const newRole = await db.roles.findByPk(data.app_role, { transaction }); + assertCanManageUserWithRole(currentUser, newRole?.name ?? null); + } + const updatedUser = await UsersDBApi.update(id, data, globalAccess, { currentUser, transaction, @@ -141,13 +193,17 @@ class UsersService { throw new ValidationError('iam.errors.deletingHimself'); } - if ( - currentUser?.app_role?.name !== config.roles.admin && - currentUser?.app_role?.name !== config.roles.super_admin - ) { - throw new ValidationError('errors.forbidden.message'); + const target = await UsersDBApi.findBy({ id }, { transaction }); + if (!target) { + throw new ValidationError('iam.errors.userNotFound'); } + // Tenant + relational policy: the target must be in the actor's org, and + // 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); + await UsersDBApi.remove(id, { currentUser, transaction }); await transaction.commit(); @@ -160,6 +216,21 @@ class UsersService { static async deleteByIds(ids: string[], currentUser?: CurrentUser) { const transaction = await db.sequelize.transaction(); try { + if (currentUser?.id && ids.includes(currentUser.id)) { + throw new ValidationError('iam.errors.deletingHimself'); + } + + for (const id of ids) { + const target = await UsersDBApi.findBy({ id }, { transaction }); + if (target) { + assertSameTenant(currentUser, target); + assertCanManageUserWithRole( + currentUser, + target.app_role?.name ?? null, + ); + } + } + await UsersDBApi.deleteByIds(ids, { currentUser, transaction }); await transaction.commit(); } catch (error) { diff --git a/backend/src/services/walkthrough_checkins.ts b/backend/src/services/walkthrough_checkins.ts index dd17693..85874b1 100644 --- a/backend/src/services/walkthrough_checkins.ts +++ b/backend/src/services/walkthrough_checkins.ts @@ -190,8 +190,8 @@ class WalkthroughCheckinsService { lesson_plan_rating: data.lesson_plan_rating, lesson_plan_comment: nullableString(data.lesson_plan_comment), overall_notes: nullableString(data.overall_notes), - organizationId, - campusId: getCampusId(currentUser), + organizationId: organizationId ?? undefined, + campusId: getCampusId(currentUser) ?? undefined, createdById: requireUserId(currentUser), updatedById: currentUser?.id ?? null, }, diff --git a/backend/src/shared/config/index.ts b/backend/src/shared/config/index.ts index c4e707a..fca0c3f 100644 --- a/backend/src/shared/config/index.ts +++ b/backend/src/shared/config/index.ts @@ -17,20 +17,32 @@ import { DEFAULT_DEV_API_PORT, DEFAULT_DEV_UI_PORT, DEFAULT_DEV_HOST, + DEFAULT_DEV_SECRET_KEY, DEFAULT_EMAIL_FROM, DEFAULT_EMAIL_HOST, DEFAULT_EMAIL_PORT, } from '@/shared/constants/app'; -import { GENERATED_ROLE_NAMES } from '@/shared/constants/roles'; +import { ROLE_NAMES } from '@/shared/constants/roles'; -function requiredEnv(name: string): string { +/** + * Returns env var value, falling back to devDefault only in development. + * Throws in production/dev_stage if the env var is not set. + */ +function requiredEnvWithDevDefault(name: string, devDefault: string): string { const value = process.env[name]; - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); + if (value) { + return value; } - return value; + const env = process.env.NODE_ENV; + if (env === 'production' || env === 'dev_stage') { + throw new Error( + `Missing required environment variable: ${name} (no dev defaults in ${env})`, + ); + } + + return devDefault; } function readBooleanEnv(name: string, defaultValue: boolean): boolean { @@ -184,8 +196,17 @@ const config = { 'AUTH_REFRESH_TOKEN_MAX_AGE_MS', REFRESH_TOKEN_EXPIRES_IN_MS, ), + // How long an expired/rotated refresh-token row is physically retained past + // its `expiresAt` before the maintenance job deletes it. The grace window + // keeps recently-expired rows around for reuse-detection forensics; once a + // token is past `expiresAt` it can no longer be presented (the cookie is + // expired), so deletion after the window is safe. Default: 7 days. + refreshTokenRetentionMs: readNumberEnv( + 'AUTH_REFRESH_TOKEN_RETENTION_MS', + 7 * 24 * 60 * 60 * 1000, + ), }, - secret_key: requiredEnv('SECRET_KEY'), + secret_key: requiredEnvWithDevDefault('SECRET_KEY', DEFAULT_DEV_SECRET_KEY), remote, port, serverPort, @@ -198,10 +219,6 @@ const config = { clientId: process.env.GOOGLE_CLIENT_ID || '', clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', }, - microsoft: { - clientId: process.env.MS_CLIENT_ID || '', - clientSecret: process.env.MS_CLIENT_SECRET || '', - }, uploadDir: os.tmpdir(), email: { from: process.env.EMAIL_FROM || DEFAULT_EMAIL_FROM, @@ -216,9 +233,9 @@ const config = { }, }, roles: { - super_admin: GENERATED_ROLE_NAMES.SUPER_ADMIN, - admin: GENERATED_ROLE_NAMES.ADMIN, - user: GENERATED_ROLE_NAMES.FINANCE_OFFICER, + super_admin: ROLE_NAMES.SUPER_ADMIN, + admin: ROLE_NAMES.SYSTEM_ADMIN, + user: ROLE_NAMES.OFFICE_MANAGER, }, host, apiUrl, diff --git a/backend/src/shared/constants/app.ts b/backend/src/shared/constants/app.ts index 620943c..ffa624d 100644 --- a/backend/src/shared/constants/app.ts +++ b/backend/src/shared/constants/app.ts @@ -4,6 +4,8 @@ export const DEFAULT_DEV_HOST = 'http://localhost'; export const DEFAULT_EMAIL_FROM = 'School Chain Manager '; export const DEFAULT_EMAIL_HOST = 'email-smtp.us-east-1.amazonaws.com'; export const DEFAULT_EMAIL_PORT = 587; -export const DEFAULT_DEV_DB_HOST = 'localhost'; -export const DEFAULT_DEV_DB_NAME = 'db_school_chain_manager'; +export const DEFAULT_DEV_DB_HOST = '127.0.0.1'; +export const DEFAULT_DEV_DB_NAME = 'schoolchain_dev'; export const DEFAULT_DEV_DB_USER = 'postgres'; +export const DEFAULT_DEV_DB_PASS = 'postgres'; +export const DEFAULT_DEV_SECRET_KEY = 'local_dev_secret_change_me'; diff --git a/backend/src/shared/constants/audio-files.test.ts b/backend/src/shared/constants/audio-files.test.ts new file mode 100644 index 0000000..b47cf15 --- /dev/null +++ b/backend/src/shared/constants/audio-files.test.ts @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AUDIO_FILE_KINDS, isAudioFileKind } from '@/shared/constants/audio-files'; + +test('the three storage kinds are file, url, recipe', () => { + assert.deepEqual([...AUDIO_FILE_KINDS], ['file', 'url', 'recipe']); +}); + +test('isAudioFileKind accepts the known kinds and rejects others', () => { + assert.equal(isAudioFileKind('file'), true); + assert.equal(isAudioFileKind('url'), true); + assert.equal(isAudioFileKind('recipe'), true); + assert.equal(isAudioFileKind('builtin'), false); + assert.equal(isAudioFileKind(''), false); + assert.equal(isAudioFileKind(null), false); + assert.equal(isAudioFileKind(undefined), false); + assert.equal(isAudioFileKind(3), false); +}); diff --git a/backend/src/shared/constants/audio-files.ts b/backend/src/shared/constants/audio-files.ts new file mode 100644 index 0000000..2b7d61e --- /dev/null +++ b/backend/src/shared/constants/audio-files.ts @@ -0,0 +1,22 @@ +/** + * Audio-library storage kinds (Workstream 13). A row in `audio_files` is one of: + * - `file` — a binary uploaded via the file subsystem; `url` references it. + * - `url` — an external link to an audio resource; `url` holds it. + * - `recipe`— a client-synthesized sound; `recipe` (JSONB) holds the parameters, + * `url` is null. Recipe rows never touch the file subsystem, so they + * are exempt from the download ownership check. + * + * Exactly one of `url` / `recipe` is populated, matching `kind`. + */ +export const AUDIO_FILE_KINDS = ['file', 'url', 'recipe'] as const; + +export type AudioFileKind = (typeof AUDIO_FILE_KINDS)[number]; + +export function isAudioFileKind(value: unknown): value is AudioFileKind { + return ( + typeof value === 'string' && + (AUDIO_FILE_KINDS as readonly string[]).includes(value) + ); +} + +export const AUDIO_FILE_DEFAULT_KIND: AudioFileKind = 'file'; diff --git a/backend/src/shared/constants/auth.ts b/backend/src/shared/constants/auth.ts index 1144492..29afef1 100644 --- a/backend/src/shared/constants/auth.ts +++ b/backend/src/shared/constants/auth.ts @@ -1,7 +1,6 @@ export const AUTH_PROVIDERS = Object.freeze({ LOCAL: 'local', GOOGLE: 'google', - MICROSOFT: 'microsoft', }); export const BCRYPT_SALT_ROUNDS = 12; diff --git a/backend/src/shared/constants/campus-attendance.ts b/backend/src/shared/constants/campus-attendance.ts index 0fb9b65..8f549f7 100644 --- a/backend/src/shared/constants/campus-attendance.ts +++ b/backend/src/shared/constants/campus-attendance.ts @@ -1,38 +1,21 @@ -import { - GENERATED_ROLE_NAMES, - GENERATED_ROLE_TO_PRODUCT_ROLE, - PRODUCT_ROLE_VALUES, - STAFF_TYPE_TO_PRODUCT_ROLE, - type ProductRoleValue, -} from './roles'; +import { ROLE_NAMES } from './roles'; export const CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, ]); export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([ ...CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, - GENERATED_ROLE_NAMES.FINANCE_OFFICER, -]); - -export const CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES = Object.freeze([ - PRODUCT_ROLE_VALUES.OFFICE, - PRODUCT_ROLE_VALUES.DIRECTOR, - PRODUCT_ROLE_VALUES.SUPERINTENDENT, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, + ROLE_NAMES.OFFICE_MANAGER, ]); export const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120; export const CAMPUS_ATTENDANCE_MAX_LIMIT = 366; -interface ProductRoleUser { - app_role?: { name?: string | null } | null; - staff_user?: Array<{ staff_type?: string | null }> | null; -} - export function normalizeCampusKey(value: unknown): string | null { if (typeof value !== 'string' || value.trim().length === 0) { return null; @@ -42,25 +25,3 @@ export function normalizeCampusKey(value: unknown): string | null { return normalized || null; } - -export function getProductRole( - currentUser: ProductRoleUser | null | undefined, -): ProductRoleValue { - const roleName = currentUser?.app_role?.name; - const staffProfile = Array.isArray(currentUser?.staff_user) - ? currentUser.staff_user[0] - : null; - - if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { - return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; - } - - if ( - staffProfile?.staff_type && - STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type] - ) { - return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type]; - } - - return PRODUCT_ROLE_VALUES.TEACHER; -} diff --git a/backend/src/shared/constants/communications.ts b/backend/src/shared/constants/communications.ts index 36df652..fa46728 100644 --- a/backend/src/shared/constants/communications.ts +++ b/backend/src/shared/constants/communications.ts @@ -1,4 +1,4 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const COMMUNICATION_CHANNELS = Object.freeze({ IN_APP: 'in_app', @@ -45,16 +45,16 @@ export const PARENT_MESSAGE_CATEGORY_VALUES: readonly ParentMessageCategory[] = export const DEFAULT_PARENT_MESSAGE_CATEGORY: ParentMessageCategory = 'general'; export const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); export const COMMUNICATION_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, ]); diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index a62e30f..d736c2b 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -1,9 +1,9 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); diff --git a/backend/src/shared/constants/frame.ts b/backend/src/shared/constants/frame.ts index 606a1a4..ef9bbc4 100644 --- a/backend/src/shared/constants/frame.ts +++ b/backend/src/shared/constants/frame.ts @@ -1,9 +1,9 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const FRAME_EDITOR_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); diff --git a/backend/src/shared/constants/personality.ts b/backend/src/shared/constants/personality.ts index be57a9b..51a22ac 100644 --- a/backend/src/shared/constants/personality.ts +++ b/backend/src/shared/constants/personality.ts @@ -1,9 +1,9 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); diff --git a/backend/src/shared/constants/policy-documents.test.ts b/backend/src/shared/constants/policy-documents.test.ts new file mode 100644 index 0000000..ab8b767 --- /dev/null +++ b/backend/src/shared/constants/policy-documents.test.ts @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + POLICY_DOCUMENT_CATEGORIES, + isPolicyDocumentCategory, + nextPolicyDocumentVersion, +} from '@/shared/constants/policy-documents'; + +// --- category validation (Workstream 11) --- + +test('isPolicyDocumentCategory accepts the two stored categories', () => { + assert.equal(isPolicyDocumentCategory(POLICY_DOCUMENT_CATEGORIES.SAFETY_PROTOCOL), true); + assert.equal(isPolicyDocumentCategory(POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY), true); +}); + +test('isPolicyDocumentCategory rejects anything else', () => { + for (const bad of ['', 'safety', 'policy', null, undefined, 42, {}]) { + assert.equal(isPolicyDocumentCategory(bad), false); + } +}); + +// --- version-bump rule (re-acknowledgment) --- + +test('editing content bumps the version', () => { + assert.equal(nextPolicyDocumentVersion(1, true), 2); + assert.equal(nextPolicyDocumentVersion(7, true), 8); +}); + +test('a metadata-only edit keeps the version', () => { + assert.equal(nextPolicyDocumentVersion(1, false), 1); + assert.equal(nextPolicyDocumentVersion(7, false), 7); +}); + +test('an explicit version overrides the rule', () => { + assert.equal(nextPolicyDocumentVersion(1, false, 5), 5); + assert.equal(nextPolicyDocumentVersion(1, true, 5), 5); +}); diff --git a/backend/src/shared/constants/policy-documents.ts b/backend/src/shared/constants/policy-documents.ts new file mode 100644 index 0000000..e4d8f9c --- /dev/null +++ b/backend/src/shared/constants/policy-documents.ts @@ -0,0 +1,41 @@ +/** + * Policy/safety document categories (Workstream 11). Two existing content areas + * require staff acknowledgment: official/government **safety protocols** and + * internal **handbook & policies**. Stored on `policy_documents.category`. + */ +export const POLICY_DOCUMENT_CATEGORIES = Object.freeze({ + SAFETY_PROTOCOL: 'safety_protocol', + HANDBOOK_POLICY: 'handbook_policy', +}); + +export type PolicyDocumentCategory = + (typeof POLICY_DOCUMENT_CATEGORIES)[keyof typeof POLICY_DOCUMENT_CATEGORIES]; + +export const POLICY_DOCUMENT_CATEGORY_VALUES: readonly PolicyDocumentCategory[] = + Object.freeze(Object.values(POLICY_DOCUMENT_CATEGORIES)); + +export const POLICY_DOCUMENTS_QUERY_KEY = 'policy_documents'; +export const POLICY_ACKNOWLEDGMENTS_QUERY_KEY = 'policy_acknowledgments'; + +/** Type guard for the stored `category` value. */ +export function isPolicyDocumentCategory( + value: unknown, +): value is PolicyDocumentCategory { + return POLICY_DOCUMENT_CATEGORY_VALUES.some((category) => category === value); +} + +/** + * Domain rule (Workstream 11): editing a document's content bumps its version so + * staff must re-acknowledge. An explicit `version` wins; otherwise the version + * increments only when the content changed. + */ +export function nextPolicyDocumentVersion( + currentVersion: number, + contentChanged: boolean, + explicitVersion?: number, +): number { + if (explicitVersion !== undefined) { + return explicitVersion; + } + return contentChanged ? currentVersion + 1 : currentVersion; +} diff --git a/backend/src/shared/constants/product-permissions.ts b/backend/src/shared/constants/product-permissions.ts new file mode 100644 index 0000000..97fa952 --- /dev/null +++ b/backend/src/shared/constants/product-permissions.ts @@ -0,0 +1,87 @@ +/** + * Product-feature permission names (Workstream 3 §3.2). These complement the + * generic `${METHOD}_${ENTITY}` CRUD permissions: `READ_` gates a + * product page, and the three action permissions gate the special workflows + * (filling attendance, taking a quiz, leaving a read receipt). + * + * Single source for both the role seeder (which seeds + grants them) and the + * feature routes (which enforce them via `checkPermissions`), so the names never + * drift between where they are granted and where they are checked. + */ + +/** Pages every campus staff role can read. */ +export const MODULE_READ_ALL_STAFF = [ + 'READ_DASHBOARD', + 'READ_FRAME', + 'READ_EI', + 'READ_ATTENDANCE', + 'READ_INTERNAL_COMM', + 'READ_SAFETY', + 'READ_HANDBOOK', +] as const; + +/** Instructional tools (teacher / support_staff, not office_manager). */ +export const MODULE_READ_INSTRUCTIONAL = [ + 'READ_CLASSROOM', + 'READ_TIMER', + 'READ_QBS', + 'READ_ZONES', + 'READ_SIGNS', +] as const; + +/** Parent communication page (teacher + managers). */ +export const MODULE_READ_PARENT_COMM = ['READ_PARENT_COMM'] as const; + +/** External-user pages (student / guardian + staff). */ +export const MODULE_READ_EXTERNAL = [ + 'READ_COMMUNITY', + 'READ_VOCATIONAL', + 'READ_ESA', +] as const; + +/** Director-only surfaces. */ +export const MODULE_READ_DIRECTOR = [ + 'READ_WALKTHROUGH', + 'READ_DIRECTOR_DASHBOARD', +] as const; + +/** Special action permissions (extendable per-user via `custom_permissions`). */ +export const MODULE_ACTIONS = [ + 'FILL_ATTENDANCE', + 'TAKE_QUIZ', + 'ACK_READ_RECEIPT', + 'ACK_POLICY', +] 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_ALL_STAFF, + ...MODULE_READ_INSTRUCTIONAL, + ...MODULE_READ_PARENT_COMM, + ...MODULE_READ_EXTERNAL, + ...MODULE_READ_DIRECTOR, + ...MODULE_ACTIONS, + ...AUDIO_PERMISSIONS, +]); + +/** + * Named references used by the feature routes when calling `checkPermissions`. + * Keeps the route wiring free of bare string literals. + */ +export const FEATURE_PERMISSIONS = Object.freeze({ + READ_FRAME: 'READ_FRAME', + READ_ATTENDANCE: 'READ_ATTENDANCE', + READ_INTERNAL_COMM: 'READ_INTERNAL_COMM', + READ_PARENT_COMM: 'READ_PARENT_COMM', + READ_SAFETY: 'READ_SAFETY', + READ_WALKTHROUGH: 'READ_WALKTHROUGH', + FILL_ATTENDANCE: 'FILL_ATTENDANCE', + TAKE_QUIZ: 'TAKE_QUIZ', + ACK_READ_RECEIPT: 'ACK_READ_RECEIPT', + ACK_POLICY: 'ACK_POLICY', + READ_AUDIO_FILES: 'READ_AUDIO_FILES', + MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', +}); diff --git a/backend/src/shared/constants/roles.ts b/backend/src/shared/constants/roles.ts index b1596d3..2e2bfbc 100644 --- a/backend/src/shared/constants/roles.ts +++ b/backend/src/shared/constants/roles.ts @@ -1,46 +1,72 @@ -export const GENERATED_ROLE_NAMES = Object.freeze({ - SUPER_ADMIN: 'Super Administrator', - ADMIN: 'Administrator', - PLATFORM_OWNER: 'Platform Owner', - TENANT_DIRECTOR: 'Tenant Director', - CAMPUS_MANAGER: 'Campus Manager', - ACADEMIC_COORDINATOR: 'Academic Coordinator', - FINANCE_OFFICER: 'Finance Officer', +/** + * Authorization scope of a role (Workstream 3 §3.1). Determines the breadth of + * a role's reach: platform-wide, a single organization, a single campus, the + * external-user surface, or the unauthenticated guest. Stored on `roles.scope`. + */ +export const ROLE_SCOPES = Object.freeze({ + SYSTEM: 'system', + ORGANIZATION: 'organization', + CAMPUS: 'campus', + EXTERNAL: 'external', + GUEST: 'guest', }); -/** Seeded roles referenced by name in code (distinct from product/staff roles). */ -export const SPECIAL_ROLE_NAMES = Object.freeze({ - /** Fallback role used for requests without an assigned role. */ - PUBLIC: 'Public', - /** Default role assigned to a newly created user. */ - DEFAULT_USER: 'User', -}); +export type RoleScope = (typeof ROLE_SCOPES)[keyof typeof ROLE_SCOPES]; -export const PRODUCT_ROLE_VALUES = Object.freeze({ - TEACHER: 'teacher', - PARA: 'para', - OFFICE: 'office', - DIRECTOR: 'director', +export const ROLE_SCOPE_VALUES: readonly RoleScope[] = Object.freeze( + Object.values(ROLE_SCOPES), +); + +/** + * The 11 first-class platform roles (Workstream 3 §3.1). The stored `roles.name` + * uses these stable machine values; `roles.scope` is the matching scope. `guest` + * is the unauthenticated fallback (the seeded `Public` row), not an assignable + * user role. `globalAccess` (system roles only) bypasses tenant filtering. + */ +export const ROLE_NAMES = Object.freeze({ + SUPER_ADMIN: 'super_admin', + SYSTEM_ADMIN: 'system_admin', + OWNER: 'owner', SUPERINTENDENT: 'superintendent', + DIRECTOR: 'director', + OFFICE_MANAGER: 'office_manager', + TEACHER: 'teacher', + SUPPORT_STAFF: 'support_staff', + STUDENT: 'student', + GUARDIAN: 'guardian', + GUEST: 'guest', }); -export type ProductRoleValue = - (typeof PRODUCT_ROLE_VALUES)[keyof typeof PRODUCT_ROLE_VALUES]; +export type RoleName = (typeof ROLE_NAMES)[keyof typeof ROLE_NAMES]; -export const GENERATED_ROLE_TO_PRODUCT_ROLE: Record = - Object.freeze({ - [GENERATED_ROLE_NAMES.SUPER_ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.PLATFORM_OWNER]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.TENANT_DIRECTOR]: PRODUCT_ROLE_VALUES.DIRECTOR, - [GENERATED_ROLE_NAMES.CAMPUS_MANAGER]: PRODUCT_ROLE_VALUES.DIRECTOR, - [GENERATED_ROLE_NAMES.ACADEMIC_COORDINATOR]: PRODUCT_ROLE_VALUES.TEACHER, - [GENERATED_ROLE_NAMES.FINANCE_OFFICER]: PRODUCT_ROLE_VALUES.OFFICE, - }); +export interface RoleDefinition { + readonly name: RoleName; + readonly scope: RoleScope; + /** Bypasses tenant/organization filtering. System roles only. */ + readonly globalAccess: boolean; +} -export const STAFF_TYPE_TO_PRODUCT_ROLE: Record = - Object.freeze({ - teacher: PRODUCT_ROLE_VALUES.TEACHER, - admin: PRODUCT_ROLE_VALUES.OFFICE, - support: PRODUCT_ROLE_VALUES.PARA, - }); +export const ROLE_DEFINITIONS: readonly RoleDefinition[] = Object.freeze([ + { name: ROLE_NAMES.SUPER_ADMIN, scope: ROLE_SCOPES.SYSTEM, globalAccess: true }, + { name: ROLE_NAMES.SYSTEM_ADMIN, scope: ROLE_SCOPES.SYSTEM, globalAccess: true }, + { name: ROLE_NAMES.OWNER, scope: ROLE_SCOPES.ORGANIZATION, globalAccess: false }, + { name: ROLE_NAMES.SUPERINTENDENT, scope: ROLE_SCOPES.ORGANIZATION, 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.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 }, +]); + +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 index c987df6..2223a94 100644 --- a/backend/src/shared/constants/safety-quiz.ts +++ b/backend/src/shared/constants/safety-quiz.ts @@ -1,9 +1,9 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); diff --git a/backend/src/shared/constants/seed-fixtures.ts b/backend/src/shared/constants/seed-fixtures.ts new file mode 100644 index 0000000..2034fb5 --- /dev/null +++ b/backend/src/shared/constants/seed-fixtures.ts @@ -0,0 +1,80 @@ +import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles'; +import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses'; +import { + USER_NAME_PREFIXES, + type UserNamePrefix, +} from '@/shared/constants/users'; + +/** + * RBAC seed fixtures (Workstream 4): one company, one campus (the `tigers` + * product campus), staff covering every campus role, 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/campus/staff links). Pre-launch — reset the DB and reseed. + */ + +export const SEED_ORGANIZATION_ID = 'b1a7c0de-0000-4000-8000-000000000001'; +export const SEED_ORGANIZATION_NAME = 'Demo Academy'; + +/** The campus the fixture staff are assigned to (the seeded `tigers` campus). */ +export const SEED_CAMPUS_ID = PRODUCT_CAMPUS_SEED_ROWS[0].id; + +export type StaffType = 'teacher' | 'admin' | 'support'; + +export interface SeedFixtureUser { + readonly id: string; + readonly email: string; + /** Honorific title; the UI renders it before the name (not baked into it). */ + readonly namePrefix?: UserNamePrefix; + readonly firstName: string; + readonly lastName: string; + readonly role: RoleName; + /** Uses `SEED_ADMIN_PASSWORD` (system roles) vs `SEED_USER_PASSWORD`. */ + readonly admin: boolean; + /** Gets `organizationId` (org/campus/external roles; not the system roles). */ + readonly organization: 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[] = [ + { id: 'b1a7c0de-0000-4000-8000-000000000010', email: 'admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MR, firstName: 'Alex', lastName: 'Morgan', role: ROLE_NAMES.SUPER_ADMIN, admin: true, organization: false, campus: false }, + { id: 'b1a7c0de-0000-4000-8000-000000000011', email: 'system_admin@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MS, firstName: 'Jordan', lastName: 'Chen', role: ROLE_NAMES.SYSTEM_ADMIN, admin: true, organization: false, campus: false }, + { id: 'b1a7c0de-0000-4000-8000-000000000012', email: 'owner@flatlogic.com', namePrefix: USER_NAME_PREFIXES.MRS, firstName: 'Patricia', lastName: 'Hayes', role: ROLE_NAMES.OWNER, admin: false, organization: true, campus: false }, + { 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, 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, 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, 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, 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, campus: true, staffType: 'support' }, + { id: 'b1a7c0de-0000-4000-8000-000000000018', email: 'student@flatlogic.com', firstName: 'Emma', lastName: 'Clark', role: ROLE_NAMES.STUDENT, admin: false, organization: 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, 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. + */ +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, + campus: false, +}; + +/** Every seeded login user: the per-role primary fixtures + the 2nd-tenant owner. */ +export const SEED_ALL_USERS: readonly SeedFixtureUser[] = [ + ...SEED_FIXTURE_USERS, + SEED_SECONDARY_OWNER, +]; diff --git a/backend/src/shared/constants/staff-attendance.ts b/backend/src/shared/constants/staff-attendance.ts index 852a0d3..c9b3ee1 100644 --- a/backend/src/shared/constants/staff-attendance.ts +++ b/backend/src/shared/constants/staff-attendance.ts @@ -1,4 +1,4 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const STAFF_ATTENDANCE_STATUSES = Object.freeze({ PRESENT: 'present', @@ -7,17 +7,17 @@ export const STAFF_ATTENDANCE_STATUSES = Object.freeze({ }); export const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); export const STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, ]); export const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90; diff --git a/backend/src/shared/constants/users.test.ts b/backend/src/shared/constants/users.test.ts new file mode 100644 index 0000000..a284643 --- /dev/null +++ b/backend/src/shared/constants/users.test.ts @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { USER_NAME_PREFIXES, formatPersonName } from '@/shared/constants/users'; + +test('formatPersonName prepends the honorific label', () => { + assert.equal( + formatPersonName(USER_NAME_PREFIXES.DR, 'Sarah', 'Williams'), + 'Dr. Sarah Williams', + ); + assert.equal(formatPersonName('mr', 'Alex', 'Morgan'), 'Mr. Alex Morgan'); +}); + +test('formatPersonName omits a missing/unknown prefix', () => { + assert.equal(formatPersonName(null, 'Emma', 'Clark'), 'Emma Clark'); + assert.equal(formatPersonName('bogus', 'Emma', 'Clark'), 'Emma Clark'); + assert.equal(formatPersonName(undefined, 'Emma', null), 'Emma'); +}); + +test('formatPersonName returns empty string when no name parts exist', () => { + assert.equal(formatPersonName(null, null, null), ''); +}); diff --git a/backend/src/shared/constants/users.ts b/backend/src/shared/constants/users.ts new file mode 100644 index 0000000..c25b41d --- /dev/null +++ b/backend/src/shared/constants/users.ts @@ -0,0 +1,48 @@ +/** + * Honorific name prefix (title) for a user — e.g. `Dr.`, `Ms.` Stored on + * `users.name_prefix` as an enum so the UI can render "Dr. Williams" even when + * the person did not type the title into their name. Display labels include the + * trailing period; the stored value is the lowercase key. + */ +export const USER_NAME_PREFIXES = Object.freeze({ + MR: 'mr', + MRS: 'mrs', + MS: 'ms', + MX: 'mx', + DR: 'dr', + PROF: 'prof', +}); + +export type UserNamePrefix = + (typeof USER_NAME_PREFIXES)[keyof typeof USER_NAME_PREFIXES]; + +export const USER_NAME_PREFIX_VALUES: readonly UserNamePrefix[] = Object.freeze( + Object.values(USER_NAME_PREFIXES), +); + +/** Stored value → human label (with trailing period). */ +export const USER_NAME_PREFIX_LABELS: Record = + Object.freeze({ + mr: 'Mr.', + mrs: 'Mrs.', + ms: 'Ms.', + mx: 'Mx.', + dr: 'Dr.', + prof: 'Prof.', + }); + +/** "Dr. Sarah Williams" from a prefix + names; omits missing parts. */ +export function formatPersonName( + namePrefix: string | null | undefined, + firstName: string | null | undefined, + lastName: string | null | undefined, +): string { + const label = + namePrefix && namePrefix in USER_NAME_PREFIX_LABELS + ? USER_NAME_PREFIX_LABELS[namePrefix as UserNamePrefix] + : null; + return [label, firstName, lastName] + .filter((part): part is string => typeof part === 'string' && part.length > 0) + .join(' ') + .trim(); +} diff --git a/backend/src/shared/constants/walkthrough.ts b/backend/src/shared/constants/walkthrough.ts index 0b2973d..f041ddb 100644 --- a/backend/src/shared/constants/walkthrough.ts +++ b/backend/src/shared/constants/walkthrough.ts @@ -1,16 +1,16 @@ -import { GENERATED_ROLE_NAMES } from './roles'; +import { ROLE_NAMES } from './roles'; export const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, + ROLE_NAMES.DIRECTOR, ]); export const WALKTHROUGH_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + ROLE_NAMES.SUPER_ADMIN, + ROLE_NAMES.SYSTEM_ADMIN, + ROLE_NAMES.OWNER, + ROLE_NAMES.SUPERINTENDENT, ]); diff --git a/backend/src/shared/notifications/helpers.ts b/backend/src/shared/notifications/helpers.ts index 9e37849..dff59d9 100644 --- a/backend/src/shared/notifications/helpers.ts +++ b/backend/src/shared/notifications/helpers.ts @@ -1,13 +1,14 @@ import errors from '@/shared/notifications/list'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + function getByPath(object: unknown, path: string): unknown { return String(path) .split('.') .reduce( - (acc, key) => - acc && typeof acc === 'object' - ? (acc as Record)[key] - : undefined, + (acc, key) => (isRecord(acc) ? acc[key] : undefined), object, ); } diff --git a/backend/src/test-utils/index.ts b/backend/src/test-utils/index.ts new file mode 100644 index 0000000..25ee63b --- /dev/null +++ b/backend/src/test-utils/index.ts @@ -0,0 +1,241 @@ +/** + * Shared test utilities for backend unit tests. + * + * This module provides test data builders and mock factories for + * consistent test setup across the codebase. + */ +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; +import type { CrudDbApi } from '@/services/shared/crud-service'; + +/** + * Creates a test user with sensible defaults. Override any properties as needed. + */ +export function createTestUser(overrides: Partial = {}): CurrentUser { + return { + id: 'test-user-id', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + organizationId: 'test-org-id', + organizations: { id: 'test-org-id' }, + app_role: { + name: 'teacher', + globalAccess: false, + }, + custom_permissions: [], + ...overrides, + }; +} + +/** + * Creates a test user with global access (super_admin/system_admin). + */ +export function createGlobalAccessUser( + overrides: Partial = {}, +): CurrentUser { + return createTestUser({ + app_role: { + name: 'super_admin', + globalAccess: true, + }, + ...overrides, + }); +} + +/** + * Configuration for mock DbApi factory. + * Requires default entity for type-safe mock returns. + */ +interface MockDbApiConfig { + defaultEntity: Entity; +} + +/** + * Generic mock factory for CrudDbApi. Returns an object with all CRUD + * methods as stubs that can be customized per test. + * + * @param config Configuration with default entity value for type-safe returns + * @param overrides Optional method overrides for specific test cases + */ +export function createMockDbApi< + CreateData, + UpdateData, + ListFilter, + BulkRow, + Entity, +>( + config: MockDbApiConfig, + overrides: Partial> = {}, +): CrudDbApi & { + calls: Record; + reset: () => void; +} { + const { defaultEntity } = config; + const calls: Record = { + create: [], + bulkImport: [], + update: [], + deleteByIds: [], + remove: [], + findBy: [], + findAll: [], + findAllAutocomplete: [], + }; + + return { + calls, + reset() { + for (const key of Object.keys(calls)) { + calls[key] = []; + } + }, + + async create(data: CreateData, options?: DbApiOptions): Promise { + calls.create.push([data, options]); + if (overrides.create) { + return overrides.create(data, options); + } + return defaultEntity; + }, + + async bulkImport(rows: BulkRow[], options?: DbApiOptions): Promise { + calls.bulkImport.push([rows, options]); + if (overrides.bulkImport) { + return overrides.bulkImport(rows, options); + } + return rows.map(() => defaultEntity); + }, + + async update( + id: string, + data: UpdateData, + options?: DbApiOptions, + ): Promise { + calls.update.push([id, data, options]); + if (overrides.update) { + return overrides.update(id, data, options); + } + return defaultEntity; + }, + + async deleteByIds(ids: string[], options?: DbApiOptions): Promise { + calls.deleteByIds.push([ids, options]); + if (overrides.deleteByIds) { + return overrides.deleteByIds(ids, options); + } + return ids.length; + }, + + async remove(id: string, options?: DbApiOptions): Promise { + calls.remove.push([id, options]); + if (overrides.remove) { + return overrides.remove(id, options); + } + return true; + }, + + async findBy(where: { id: string }, options?: DbApiOptions): Promise { + calls.findBy.push([where, options]); + if (overrides.findBy) { + return overrides.findBy(where, options); + } + return defaultEntity; + }, + + async findAll( + filter: ListFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Entity[]; count: number }> { + calls.findAll.push([filter, globalAccess, options]); + if (overrides.findAll) { + return overrides.findAll(filter, globalAccess, options); + } + return { rows: [], count: 0 }; + }, + + async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ): Promise { + calls.findAllAutocomplete.push([query, limit, offset, globalAccess, organizationId]); + if (overrides.findAllAutocomplete) { + return overrides.findAllAutocomplete(query, limit, offset, globalAccess, organizationId); + } + return []; + }, + }; +} + +/** + * Creates mock options for DbApi calls in tests. + */ +export function createMockOptions( + overrides: Partial = {}, +): DbApiOptions { + return { + currentUser: createTestUser(), + ...overrides, + }; +} + +/** + * Mock request object for controller/service tests. + */ +export function createMockRequest(overrides: Record = {}) { + return { + headers: { + 'user-agent': 'test-agent', + }, + ip: '127.0.0.1', + socket: { + remoteAddress: '127.0.0.1', + }, + cookies: {}, + body: {}, + query: {}, + params: {}, + ...overrides, + }; +} + +/** + * Simple stub that tracks calls and returns a configurable value. + */ +export function createStub(returnValue?: T) { + const calls: unknown[][] = []; + + const stub = (...args: unknown[]) => { + calls.push(args); + return returnValue; + }; + + stub.calls = calls; + stub.reset = () => { + calls.length = 0; + }; + + return stub; +} + +/** + * Async stub variant. + */ +export function createAsyncStub(returnValue?: T) { + const calls: unknown[][] = []; + + const stub = async (...args: unknown[]) => { + calls.push(args); + return returnValue; + }; + + stub.calls = calls; + stub.reset = () => { + calls.length = 0; + }; + + return stub; +} diff --git a/backend/src/types/vendor.d.ts b/backend/src/types/vendor.d.ts deleted file mode 100644 index eaebcfa..0000000 --- a/backend/src/types/vendor.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Ambient type declarations for packages that ship without their own types -// and have no @types package. Minimal, scoped to our usage. - -declare module 'passport-google-oauth2' { - import { Strategy as PassportStrategy } from 'passport-strategy'; - - interface GoogleStrategyOptions { - clientID: string; - clientSecret: string; - callbackURL: string; - passReqToCallback?: boolean; - } - - interface GoogleProfile { - email?: string; - [key: string]: unknown; - } - - type GoogleVerifyCallback = (error: unknown, user?: unknown) => void; - - type GoogleVerifyFunction = ( - request: unknown, - accessToken: string, - refreshToken: string, - profile: GoogleProfile, - done: GoogleVerifyCallback, - ) => void; - - export class Strategy extends PassportStrategy { - constructor(options: GoogleStrategyOptions, verify: GoogleVerifyFunction); - } -} - -declare module 'passport-microsoft' { - import { Strategy as PassportStrategy } from 'passport-strategy'; - - interface MicrosoftStrategyOptions { - clientID: string; - clientSecret: string; - callbackURL: string; - passReqToCallback?: boolean; - } - - interface MicrosoftProfile { - _json: { mail?: string; userPrincipalName?: string }; - [key: string]: unknown; - } - - type MicrosoftVerifyCallback = (error: unknown, user?: unknown) => void; - - type MicrosoftVerifyFunction = ( - request: unknown, - accessToken: string, - refreshToken: string, - profile: MicrosoftProfile, - done: MicrosoftVerifyCallback, - ) => void; - - export class Strategy extends PassportStrategy { - constructor( - options: MicrosoftStrategyOptions, - verify: MicrosoftVerifyFunction, - ); - } -} diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..4f52b1b --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,49 @@ +# 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. + +## Remaining work at a glance + +- ⛔ **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`. +- **Last:** delete `ref-frontend/` once the generic-CRUD UIs (it is their reference) are built. + +## Endpoint wiring + +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). + +### To wire during frontend implementation (generic CRUD) + +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: + +`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`. + +> Wiring `roles` / `permissions` / `users` unblocks the roles/permissions admin UI and lets `` gate real create/edit/delete affordances. + +### Decision-gated extras (keep only if the workflow lands) + +- **`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. + +## Cross-cutting open gaps + +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). + +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). + +Files: +- Upload-side per-file ownership + a typed frontend upload client — only after the file UI lands. (Download ownership is already enforced.) + +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. + +Phase 5 — operations & cleanup: +- **`ref-frontend/` removal** — last; after the generic-CRUD UIs are built (it is their reference). diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md index 5f6fd84..ae52445 100644 --- a/docs/deployment-docker.md +++ b/docs/deployment-docker.md @@ -1,56 +1,55 @@ -# Развёртывание через Docker (на хосте) +# Docker Deployment (On Host) -Альтернативный способ запуска для локального стека и портируемого деплоя. -На боевой VM Flatlogic **Docker не используется** — там PM2 + локальный PostgreSQL -+ Cloudflare tunnel (см. [`deployment-vm.md`](./deployment-vm.md)). +Alternative way to run the project for local stack and portable deployment. +On the production Flatlogic VM **Docker is not used** — there it's PM2 + local PostgreSQL ++ Cloudflare tunnel (see [`deployment-vm.md`](./deployment-vm.md)). -Проект состоит из двух приложений: +The project consists of two applications: -- `frontend/` — Vite + React + TypeScript (SPA). Сборка → `frontend/dist/`. -- `backend/` — Express + Sequelize на TypeScript/ESM. Сборка → `backend/dist/`. +- `frontend/` — Vite + React + TypeScript (SPA). Build output → `frontend/dist/`. +- `backend/` — Express + Sequelize on TypeScript/ESM. Build output → `backend/dist/`. -## Файлы +## Files -В корне репозитория: +In the repository root: -| Файл | Назначение | Порт | +| File | Purpose | Port | |---|---|---| -| `Dockerfile` | прод single-image: компилированный бэк отдаёт API **и** SPA (из `public`) | 8080 | -| `Dockerfile.dev` | staging-реплика VM: nginx + фронт `vite preview` (3001) + бэк (3000), `dev_stage` | 8080 | -| `docker/docker-compose.yml` | локальный стек: PostgreSQL + app (из `Dockerfile`), `NODE_ENV=development` | 8080 | +| `Dockerfile` | production single-image: compiled backend serves API **and** SPA (from `public`) | 8080 | +| `Dockerfile.dev` | staging VM replica: nginx + frontend `vite preview` (3001) + backend (3000), `dev_stage` | 8080 | +| `docker/docker-compose.yml` | local stack: PostgreSQL + app (from `Dockerfile`), `NODE_ENV=development` | 8080 | -Все стейджи на `node:24-alpine`, `npm ci`. Нативный `bcrypt` собирается тулчейном -(`python3 make g++`) в builder-стейдже, в рантайм копируется готовый `node_modules` -(без перекомпиляции). `rolldown` (бандлер Vite) имеет musl-биндинги, поэтому alpine -подходит. +All stages use `node:24-alpine`, `npm ci`. Native `bcrypt` is compiled with the toolchain +(`python3 make g++`) in the builder stage; the compiled `node_modules` is copied to runtime +(without recompilation). `rolldown` (Vite bundler) has musl bindings, so alpine works. -## 1. Быстрый старт — docker compose (рекомендуется) +## 1. Quick Start — docker compose (Recommended) -Поднимает PostgreSQL + приложение одной командой. Бэкенд в `development` отдаёт API -и SPA на одном порту (логин работает по http). +Starts PostgreSQL + application with a single command. Backend in `development` serves API +and SPA on the same port (login works over http). ```bash cd docker docker compose up --build -# открыть http://localhost:8080 +# open http://localhost:8080 ``` -Параметры (env заданы в `docker-compose.yml`, меняйте под себя): +Parameters (env vars set in `docker-compose.yml`, modify as needed): - `SECRET_KEY=local_dev_secret_change_me` -- `DB_*` указывают на сервис `db` (Postgres 16, БД/пользователь `app_local`) -- `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `SEED_USER_PASSWORD` +- `DB_*` point to the `db` service (Postgres 16, DB/user `app_local`) +- Seed passwords are hardcoded in the seeder (see `CLAUDE.md` for credentials) -Остановить и удалить (вместе с данными БД): +Stop and remove (including DB data): ```bash docker compose down -v ``` -## 2. Прод single-image (`Dockerfile`) +## 2. Production Single-Image (`Dockerfile`) -Один контейнер: компилированный бэкенд на `NODE_ENV=production` слушает 8080 и -отдаёт и `/api`, и собранный SPA (фронт кладётся в `public`). +One container: compiled backend on `NODE_ENV=production` listens on 8080 and +serves both `/api` and the built SPA (frontend is placed in `public`). ```bash docker build -t schoolchain:prod . @@ -58,42 +57,52 @@ docker build -t schoolchain:prod . docker run --rm -p 8080:8080 \ -e NODE_ENV=production \ -e PORT=8080 \ - -e SECRET_KEY=<секрет> \ - -e ALLOWED_ORIGINS=https://<ваш-домен> \ + -e SECRET_KEY= \ + -e ALLOWED_ORIGINS=https:// \ -e DB_HOST= -e DB_PORT=5432 -e DB_NAME= -e DB_USER= -e DB_PASS= \ - -e SEED_ADMIN_EMAIL= -e SEED_ADMIN_PASSWORD= -e SEED_USER_PASSWORD= \ schoolchain:prod ``` -> В `NODE_ENV=production` обязательны `SECRET_KEY` и `ALLOWED_ORIGINS`, а cookie -> идут с флагом `Secure` (нужен HTTPS-фронт перед контейнером). Команда запуска -> (`npm run start:production`) сама прогоняет миграции и сидеры перед стартом — -> БД должна быть доступна. +> In `NODE_ENV=production`, `SECRET_KEY` and `ALLOWED_ORIGINS` are required, and cookies +> have the `Secure` flag (HTTPS frontend needed in front of the container). The startup +> command (`npm run start:production`) automatically runs migrations and seeders — +> the DB must be accessible. -## 3. Staging-реплика VM (`Dockerfile.dev`) +## 3. Staging VM Replica (`Dockerfile.dev`) -Повторяет схему VM в одном образе: nginx (8080) → фронт `vite preview` (3001) + бэк -(3000), `NODE_ENV=dev_stage`, source maps. Запускать за HTTPS (в `dev_stage` -cookie — `Secure`). +Replicates the VM setup in one image: nginx (8080) → frontend `vite preview` (3001) + backend +(3000), `NODE_ENV=dev_stage`, source maps. Run behind HTTPS (in `dev_stage` +cookies are `Secure`). ```bash docker build -t schoolchain:staging -f Dockerfile.dev . docker run --rm -p 8080:8080 \ - -e SECRET_KEY=<секрет> \ + -e SECRET_KEY= \ -e DB_HOST= -e DB_PORT=5432 -e DB_NAME= -e DB_USER= -e DB_PASS= \ - -e SEED_ADMIN_EMAIL= -e SEED_ADMIN_PASSWORD= -e SEED_USER_PASSWORD= \ schoolchain:staging ``` ## 4. `.dockerignore` -Исключает `node_modules`, `dist`, `public`, `**/.env` (чтобы не запекать dev-секреты -в образ — окружение задаётся при запуске), `.git`, логи. +Excludes `node_modules`, `dist`, `public`, `**/.env` (to avoid baking dev secrets +into the image — environment is set at runtime), `.git`, logs. -## 5. NODE_ENV — что выбрать +## 5. NODE_ENV — What to Choose -| `NODE_ENV` | Где | Особенности | +| `NODE_ENV` | Where | Characteristics | |---|---|---| -| `development` | локальный `docker compose` | http-логин работает; нет требований к `ALLOWED_ORIGINS`; cookie без `Secure` | -| `dev_stage` | `Dockerfile.dev` (staging) | прод-подобный, но мягкий; нужен HTTPS (`Secure` cookie); рефлектит origin | -| `production` | `Dockerfile` (прод) | обязательны `SECRET_KEY` + `ALLOWED_ORIGINS`; `Secure` cookie; строгий CORS | +| `development` | local `docker compose` | http login works; no `ALLOWED_ORIGINS` requirement; cookie without `Secure` | +| `dev_stage` | `Dockerfile.dev` (staging) | production-like but lenient; requires HTTPS (`Secure` cookie); reflects origin | +| `production` | `Dockerfile` (prod) | requires `SECRET_KEY` + `ALLOWED_ORIGINS`; `Secure` cookie; strict CORS | + +## 6. Scheduled maintenance: refresh-token cleanup + +Run the retention cleanup periodically (host cron or a scheduled one-off +container), e.g. daily: + +```bash +docker exec npm run db:cleanup-tokens:prod +``` + +Deletes refresh-token rows past `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 +days); idempotent and session-safe. See `backend/docs/cookie-auth.md`. diff --git a/docs/deployment-vm.md b/docs/deployment-vm.md index d439c4c..7d91e01 100644 --- a/docs/deployment-vm.md +++ b/docs/deployment-vm.md @@ -1,24 +1,24 @@ -# Развёртывание на виртуальной машине (Flatlogic executor) +# VM Deployment (Flatlogic Executor) -Так работает прод/превью Flatlogic: без Docker — PM2 + локальный PostgreSQL + -Cloudflare tunnel. В конце — справочная **структура файлов VM** (executor, pm2, -проект). +This is how Flatlogic production/preview works: no Docker — PM2 + local PostgreSQL + +Cloudflare tunnel. The end of this document contains a reference **VM file structure** +(executor, pm2, project). -> Запуск **через Docker на хосте** (compose / single-image / staging) вынесен в -> отдельный документ: [`deployment-docker.md`](./deployment-docker.md). +> **Docker deployment** (compose / single-image / staging) is covered in a separate +> document: [`deployment-docker.md`](./deployment-docker.md). -Проект состоит из двух приложений: +The project consists of two applications: -- `frontend/` — Vite + React + TypeScript (SPA). Сборка → `frontend/dist/`. -- `backend/` — Express + Sequelize на TypeScript/ESM. Сборка → `backend/dist/`. +- `frontend/` — Vite + React + TypeScript (SPA). Build output → `frontend/dist/`. +- `backend/` — Express + Sequelize on TypeScript/ESM. Build output → `backend/dist/`. -Проект живёт в `~/executor/workspace`, процессы — под PM2, БД — локальный -PostgreSQL, наружу — Cloudflare tunnel (`cloudflared`). Docker здесь не участвует. +The project lives in `~/executor/workspace`, processes are managed by PM2, DB is local +PostgreSQL, external access via Cloudflare tunnel (`cloudflared`). Docker is not used here. -## 1.1. Топология +## 1.1. Topology ``` - Браузер + Browser │ https://.dev.flatlogic.app ▼ cloudflared (tunnel) @@ -30,96 +30,112 @@ PostgreSQL, наружу — Cloudflare tunnel (`cloudflared`). Docker здес └── /api-docs → backend :3000 │ ▼ - PostgreSQL :5432 (локальный) + PostgreSQL :5432 (local) ``` -- В `NODE_ENV=dev_stage` бэкенд слушает **3000** (`config.serverPort`), фронт — **3001**. -- Браузер открывает домен туннеля; фронт зовёт API относительным путём `/api` - (см. `frontend/src/shared/constants/api.ts`), nginx проксирует его на бэкенд — - тот же origin, поэтому CORS/CSRF не мешают. +- In `NODE_ENV=dev_stage` the backend listens on **3000** (`config.serverPort`), frontend on **3001**. +- The browser opens the tunnel domain; the frontend calls the API via relative path `/api` + (see `frontend/src/shared/constants/api.ts`), nginx proxies it to the backend — + same origin, so CORS/CSRF are not a problem. -## 1.2. Окружение (env) +## 1.2. Environment Variables -**Инжектит платформа** в pm2-окружение процесса бэкенда (значения — секреты, в -репозиторий не коммитятся): +**Injected by the platform** into the pm2 environment of the backend process (values are +secrets, not committed to the repository): -| Переменная | Назначение | +| Variable | Purpose | |---|---| -| `NODE_ENV=dev_stage` | режим (прод-подобный, но без жёстких проверок прода) | -| `SECRET_KEY` | подпись JWT (обязательна — иначе бэкенд не стартует) | -| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | подключение к локальному Postgres (`DB_PASS` = UUID проекта) | -| `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET` | OAuth (опционально) | -| `SMTP_*`, `EMAIL_*`, `MAIL_*` | почта (опционально) | -| `CF_TUNNEL_*` | Cloudflare tunnel (для `cloudflared`, не для приложения) | +| `NODE_ENV=dev_stage` | mode (production-like, but without strict production checks) | +| `SECRET_KEY` | JWT signature (required — otherwise backend won't start) | +| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | connection to local Postgres (`DB_PASS` = project UUID) | +| `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET` | OAuth (optional) | +| `SMTP_*`, `EMAIL_*`, `MAIL_*` | email (optional) | +| `CF_TUNNEL_*` | Cloudflare tunnel (for `cloudflared`, not for the application) | -**Из закоммиченного `backend/.env`** (не секреты уровня прода): +**From committed `backend/.env`** (not production-level secrets): -- `PORT`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `SEED_USER_PASSWORD`. -- `backend/src/config/load-env.ts` находит `backend/.env` одинаково и для `tsx`, и - для компилированного `dist`. +- `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. +- `backend/src/shared/config/load-env.ts` finds `backend/.env` the same way for both `tsx` and + compiled `dist`. -> `ALLOWED_ORIGINS` платформа НЕ задаёт. В `dev_stage` это допустимо: бэкенд не -> падает и рефлектит origin запроса (`config.auth.allowAllOrigins`). Жёсткая -> проверка `ALLOWED_ORIGINS` действует только при строгом `NODE_ENV=production`. +> `ALLOWED_ORIGINS` is NOT set by the platform. In `dev_stage` this is acceptable: the +> backend doesn't crash and reflects the request origin (`config.auth.allowAllOrigins`). +> Strict `ALLOWED_ORIGINS` check only applies in strict `NODE_ENV=production`. -## 1.3. PM2-процессы +## 1.3. PM2 Processes -`pm2 status` на VM показывает (имена закреплены при провижининге): +`pm2 status` on the VM shows (names are fixed during provisioning): -| Процесс | Команда (cwd) | Порт | +| Process | Command (cwd) | Port | |---|---|---| | `frontend-dev` | `npm run dev -- --hostname 0.0.0.0 --port 3001` (`workspace/frontend`) | 3001 | | `backend-dev` | `NODE_ENV=dev_stage npm run start` (`workspace/backend`) | 3000 | -| `fl-executor` | `executor.js` — агент Flatlogic (cable, git, AI-команды) | — | +| `fl-executor` | `executor.js` — Flatlogic agent (cable, git, AI commands) | — | | `fl-telemetry` | `telemetry-daemon.js` | — | -> ⚠️ `--hostname` — это флаг Next.js; Vite-CLI его отвергает (`Unknown option`). -> Поэтому `dev`/`start` фронта запускаются через обёртку `frontend/scripts/serve.mjs`, -> которая транслирует `--hostname`→`--host` и стартует Vite. Без неё фронт на VM -> не поднимется. +> ⚠️ `--hostname` is a Next.js flag; Vite CLI rejects it (`Unknown option`). +> Therefore `dev`/`start` for the frontend run through the wrapper `frontend/scripts/serve.mjs`, +> which translates `--hostname`→`--host` and starts Vite. Without it, the frontend won't +> start on the VM. -## 1.4. Деплой после `git pull` +## 1.4. Deployment After `git pull` -PM2-команды зависимости **не ставят**. После подтягивания нового кода: +PM2 commands **do not install dependencies**. After pulling new code: ```bash cd ~/executor/workspace/backend && npm ci cd ~/executor/workspace/frontend && npm ci ``` -Схема БД создаётся инициальной миграцией. На уже существующей БД `npm run start` -сам прогонит `db:migrate` (идемпотентно, `CREATE TABLE IF NOT EXISTS`) + `db:seed`. -Для гарантированно чистого состояния (рекомендуется после крупной миграции — -**сотрёт данные**): +The DB schema is created by the initial migration. On an existing DB, `npm run start` +runs `db:migrate` (idempotent, `CREATE TABLE IF NOT EXISTS`) + `db:seed` automatically. +For a guaranteed clean state (recommended after major migrations — +**will delete data**): ```bash cd ~/executor/workspace/backend && npm run db:reset # drop all tables → migrate → seed ``` -Перезапуск процессов (или это делает executor после pull): +Restart processes (or the executor does this after pull): ```bash pm2 restart backend-dev frontend-dev --update-env ``` -### Что делают команды запуска +### Scheduled maintenance: refresh-token cleanup -- `backend-dev` → `npm run start` = `db:migrate` (инициальная миграция → схема) + - `db:seed` + `watch` (сервер через `tsx` + nodemon, порт 3000). -- `frontend-dev` → `npm run dev` (через `serve.mjs`) — Vite dev-сервер на 3001, - `allowedHosts: true` пускает домен туннеля. +Expired refresh-token rows accumulate (the table is not paranoid). Schedule the +cleanup command (e.g. a daily cron) to delete rows past the retention window +(`AUTH_REFRESH_TOKEN_RETENTION_MS`, default 7 days): + +```bash +# crontab -e — daily at 03:30 +30 3 * * * cd ~/executor/workspace/backend && npm run db:cleanup-tokens >> ~/token-cleanup.log 2>&1 +``` + +It is idempotent and never touches valid sessions. See +`backend/docs/cookie-auth.md` (Operational maintenance). + +### What the Startup Commands Do + +- `backend-dev` → `npm run start` = `db:migrate` (initial migration → schema) + + `db:seed` + `watch` (server via `tsx` + nodemon, port 3000). +- `frontend-dev` → `npm run dev` (via `serve.mjs`) — Vite dev server on 3001, + `allowedHosts: true` allows the tunnel domain. ## 1.5. nginx -Если nginx — системный сервис, его конфиг должен соответствовать `nginx.conf` из -репозитория (`/` → 3001, `/api` и `/api-docs` → 3000): +If nginx is a system service, its config should match `nginx.conf` from the +repository (`/` → 3001, `/api` and `/api-docs` → 3000): ```bash sudo cp ~/executor/workspace/nginx.conf /etc/nginx/nginx.conf sudo nginx -t && sudo nginx -s reload ``` -## 1.6. Проверка +## 1.6. Verification ```bash curl -s -o /dev/null -w "front %{http_code}\n" http://127.0.0.1:3001/ @@ -128,14 +144,14 @@ pm2 status pm2 logs backend-dev --lines 50 ``` -## 1.7. Прод-режим на VM (компилированные сборки) +## 1.7. Production Mode on VM (Compiled Builds) -«Прод-режим» = **компилированные сборки при сохранённом `NODE_ENV=dev_stage`** -(минификация фронта, `node dist` бэка, source maps). Строгий `NODE_ENV=production` -использовать нельзя — он требует `ALLOWED_ORIGINS`, которого платформа не задаёт, -и бэкенд упадёт на старте. +"Production mode" = **compiled builds while keeping `NODE_ENV=dev_stage`** +(frontend minification, `node dist` for backend, source maps). Strict `NODE_ENV=production` +cannot be used — it requires `ALLOWED_ORIGINS`, which the platform doesn't set, +and the backend will crash on startup. -Для перевода в прод нужно сменить pm2-команды (на стороне executor): +To switch to production, change the pm2 commands (on the executor side): ```bash # Frontend @@ -143,7 +159,7 @@ cd ~/executor/workspace/frontend && npm ci && npm run build pm2 delete frontend-dev 2>/dev/null || true FRONT_PORT=3001 pm2 start npm --name frontend --update-env -- run start -# Backend (NODE_ENV=dev_stage сохраняем) +# Backend (keep NODE_ENV=dev_stage) cd ~/executor/workspace/backend && npm ci && npm run build pm2 delete backend-dev 2>/dev/null || true NODE_ENV=dev_stage pm2 start npm --name backend --update-env -- run start:production @@ -151,100 +167,100 @@ NODE_ENV=dev_stage pm2 start npm --name backend --update-env -- run start:produc pm2 save ``` -- `frontend npm run start` = `vite preview` на `FRONT_PORT` (3001), отдаёт `dist/`. +- `frontend npm run start` = `vite preview` on `FRONT_PORT` (3001), serves `dist/`. - `backend npm run start:production` = `db:migrate:prod` + `db:seed:prod` + - `node --enable-source-maps dist/index.js` (порт 3000). + `node --enable-source-maps dist/index.js` (port 3000). -Чтобы executor сам пересобирал прод при каждом обновлении кода — добавить -`npm ci && npm run build` в его шаг рестарта сервисов (см. `vcs.js`, ~строка 412, -массив `const services = ['backend-dev', 'frontend-dev']`). +To have the executor automatically rebuild production on each code update — add +`npm ci && npm run build` to its service restart step (see `vcs.js`, ~line 412, +array `const services = ['backend-dev', 'frontend-dev']`). -## 1.8. Траблшутинг +## 1.8. Troubleshooting -| Симптом | Причина / решение | +| Symptom | Cause / Solution | |---|---| -| Фронт не стартует, `Unknown option --hostname` | старый код без `serve.mjs`; обнови workspace (`git pull` + `npm ci`) | -| Бэкенд падает: `ALLOWED_ORIGINS must be configured` | запущен с `NODE_ENV=production`; на VM должно быть `dev_stage` | -| Бэкенд падает: `SECRET_KEY` required | платформа не прокинула `SECRET_KEY` в pm2-env | -| `tsx: not found` / `vite: not found` | не выполнен `npm ci` после pull | -| Сид падает: `Seeding requires SEED_*` | нет `backend/.env` (или переменных в нём) | -| 502 на домене | бэк/фронт не слушают 3000/3001, либо nginx-роутинг не совпадает | +| Frontend won't start, `Unknown option --hostname` | old code without `serve.mjs`; update workspace (`git pull` + `npm ci`) | +| Backend crashes: `ALLOWED_ORIGINS must be configured` | running with `NODE_ENV=production`; on VM it should be `dev_stage` | +| Backend crashes: `SECRET_KEY` required | platform didn't pass `SECRET_KEY` to pm2-env (required in production/dev_stage) | +| Backend crashes: `Missing required database credentials` | DB_* not set in production/dev_stage (dev defaults don't apply) | +| `tsx: not found` / `vite: not found` | `npm ci` not run after pull | +| 502 on domain | backend/frontend not listening on 3000/3001, or nginx routing mismatch | --- -# Часть 2. Структура файлов на VM (справочно) +# Part 2. VM File Structure (Reference) -Снимок боевой VM (`pool-saas-*`). Полезно для понимания, где что лежит. +Snapshot of a production VM (`pool-saas-*`). Useful for understanding where things are located. -## 2.1. Домашняя директория `~` +## 2.1. Home Directory `~` ``` ~/ -├── executor/ # агент Flatlogic + сам проект (см. ниже) -├── .pm2/ # PM2: процессы, логи, pids -│ ├── logs/ # *-out.log, *-error.log по процессам +├── executor/ # Flatlogic agent + the project itself (see below) +├── .pm2/ # PM2: processes, logs, pids +│ ├── logs/ # *-out.log, *-error.log per process │ ├── pids/ -│ └── dump.pm2 # сохранённый список процессов (pm2 save) -├── .bun/ .yarn/ .npm/ .cache/ # тулчейны/кэши -├── .codex/ .gemini/ .config/ # конфиги AI-CLI +│ └── dump.pm2 # saved process list (pm2 save) +├── .bun/ .yarn/ .npm/ .cache/ # toolchains/caches +├── .codex/ .gemini/ .config/ # AI CLI configs ├── .ssh/ .pki/ ├── recipes.md └── google-gemini-cli-0.17.1.tgz ``` -## 2.2. `~/executor/` — агент Flatlogic +## 2.2. `~/executor/` — Flatlogic Agent ``` ~/executor/ -├── .env # конфиг агента и проекта (PROJECT_UUID, PROJECT_ID, +├── .env # agent and project config (PROJECT_UUID, PROJECT_ID, │ # CABLE_URL, DB_NAME=app_, SUBDOMAIN, FRONT_PORT, ...) -├── executor.js # главный процесс агента (pm2: fl-executor): -│ # WebSocket-cable к flatlogic.com, приём команд, -│ # запуск AI-раннеров (gemini/codex), fs/git-операции -├── gemini.js / gemini-proc.js / opencode.js / opencode-proc.js # AI-раннеры -├── vcs.js и vcs/vcs.js # git: init/pull/push/commit, Gitea-зеркало, -│ # рестарт сервисов (массив ['backend-dev','frontend-dev']) -├── vm-tools.js # инструменты VM (команды от платформы) -├── activity-tracker.js # трекинг активности раннера -├── telemetry-daemon.js / telemetry-server.js / telemetry-file-watcher.js # телеметрия (pm2: fl-telemetry) +├── executor.js # main agent process (pm2: fl-executor): +│ # WebSocket cable to flatlogic.com, receiving commands, +│ # launching AI runners (gemini/codex), fs/git operations +├── gemini.js / gemini-proc.js / opencode.js / opencode-proc.js # AI runners +├── vcs.js and vcs/vcs.js # git: init/pull/push/commit, Gitea mirror, +│ # service restart (array ['backend-dev','frontend-dev']) +├── vm-tools.js # VM tools (commands from platform) +├── activity-tracker.js # runner activity tracking +├── telemetry-daemon.js / telemetry-server.js / telemetry-file-watcher.js # telemetry (pm2: fl-telemetry) ├── sentry.js # Sentry -├── config.js # WORKSPACE_ROOT и пр. -├── index.php # статичная страница-прелоадер («Analyzing your requirements…») -├── setup_postgres_project.sh # создание роли/БД проекта в локальном Postgres -├── setup_mariadb_project.sh # то же для MariaDB +├── config.js # WORKSPACE_ROOT etc. +├── index.php # static preloader page ("Analyzing your requirements...") +├── setup_postgres_project.sh # create role/DB for project in local Postgres +├── setup_mariadb_project.sh # same for MariaDB ├── setup_workspace_permissions.sh ├── cleanup_vm.sh -├── AGENTS.md / README.md # инструкции для AI-агента (описывают шаблон проекта) +├── AGENTS.md / README.md # instructions for AI agent (describe project template) ├── otel-local.yaml / schema.json / proto/ ├── node_modules/ package.json package-lock.json -├── workspace/ # ◀── САМ ПРОЕКТ (git-репозиторий = этот репозиторий) -├── workspace_baseline.tar.gz # базовый снимок workspace -├── workspace_codegen/ # рабочая область кодогенерации -└── templates/ # шаблоны для новых проектов +├── workspace/ # ◀── THE PROJECT ITSELF (git repository = this repository) +├── workspace_baseline.tar.gz # baseline workspace snapshot +├── workspace_codegen/ # code generation workspace +└── templates/ # templates for new projects ├── app-templates/ - └── frontend-tailwind-backend-nodejs/ # Next.js+Node шаблон (НЕ наш стек) + └── frontend-tailwind-backend-nodejs/ # Next.js+Node template (NOT our stack) ``` -> Файлы `executor/` (агент) и `templates/` к запуску **нашего** проекта отношения -> не имеют — их менять не нужно. `index.php` — только прелоадер на время генерации. +> Files in `executor/` (agent) and `templates/` are not related to running **our** project — +> they don't need to be modified. `index.php` is just a preloader during generation. -## 2.3. `~/executor/workspace/` — проект +## 2.3. `~/executor/workspace/` — The Project -Это и есть данный git-репозиторий (`40227-vm`): +This is the git repository (`40227-vm`): ``` workspace/ -├── frontend/ # Vite + React + TS → собирается в frontend/dist, сервится на :3001 -├── backend/ # Express + Sequelize TS/ESM → backend/dist, сервится на :3000 -│ ├── .env # PORT, SEED_* (закоммичен) -│ └── src/db/migrations/ # инициальная миграция (схема) -├── nginx.conf # роутинг / → 3001, /api → 3000 (для системного nginx) -├── Dockerfile / Dockerfile.dev / docker/ # альтернативный Docker-путь +├── frontend/ # Vite + React + TS → builds to frontend/dist, served on :3001 +├── backend/ # Express + Sequelize TS/ESM → backend/dist, served on :3000 +│ ├── .env # PORT (committed) +│ └── src/db/migrations/ # initial migration (schema) +├── nginx.conf # routing / → 3001, /api → 3000 (for system nginx) +├── Dockerfile / Dockerfile.dev / docker/ # alternative Docker path ├── 502.html -└── docs/ # в т.ч. этот файл +└── docs/ # including this file ``` -## 2.4. `~/executor/.env` — ключи (значения секретны/индивидуальны) +## 2.4. `~/executor/.env` — Keys (Values are Secret/Individual) ``` PROJECT_UUID, PROJECT_ID, CABLE_URL, GEMINI_MODEL, TELEMETRY_*, OTEL_*, @@ -253,6 +269,6 @@ DB_NAME=app_, DB_USER=app_, DB_HOST=127.0.0.1, DB_PORT=5432, FRONT_PORT=3001, SENTRY_DSN ``` -Секреты приложения (`SECRET_KEY`, `DB_PASS`, OAuth/SMTP, токены git, `CF_TUNNEL_*`) -платформа кладёт прямо в **pm2-окружение** процессов `backend-dev`/`fl-executor`, а -не в `backend/.env`. +Application secrets (`SECRET_KEY`, `DB_PASS`, OAuth/SMTP, git tokens, `CF_TUNNEL_*`) +are placed by the platform directly into the **pm2 environment** of `backend-dev`/`fl-executor` +processes, not in `backend/.env`. diff --git a/docs/development-path.md b/docs/development-path.md deleted file mode 100644 index afae43e..0000000 --- a/docs/development-path.md +++ /dev/null @@ -1,175 +0,0 @@ -# Development Path - -## Context - -The repository currently contains three relevant parts: - -- `backend/`: generated Node.js/Express backend with PostgreSQL, Sequelize models, JWT auth, roles, permissions, email services, file handling, Swagger, and school-domain entities. -- `frontend/`: customer-approved Vite/React interface with Tailwind, Radix/shadcn UI, React Query, role-based modules, backend API integration, and intentionally static product content. This is the frontend that will be developed going forward. -- `ref-frontend/`: generated Next.js frontend with CRUD pages and Redux slices for the generated backend entities. This is a temporary reference only and can be removed after the working frontend is fully integrated. - -The customer-approved interface defines the expected product experience. The generated backend already covers much of the SaaS infrastructure and core school-chain data model. - -## Decision - -Use `frontend/` as the product frontend base and keep `backend/` as the backend base. - -Do not continue building on the generated Next.js frontend UI in `ref-frontend/`. Its most useful parts are API usage patterns, auth flow, entity coverage, and contracts with the generated backend. The visual layer and user workflows should come from `frontend/`. - -## Rationale - -This path is the least risky and most direct because: - -- The approved UI is already in `frontend/`; rebuilding it inside the generated reference frontend would duplicate work and risk visual drift. -- The generated backend already has entities for organizations, campuses, staff, students, guardians, classes, attendance, assessments, messages, documents, roles, permissions, invoices, payments, and email flows. -- The generated reference frontend uses a different stack from the approved UI: Next.js, MUI, Redux, and i18n. The active approved UI uses Vite, React 19, Tailwind, Radix/shadcn, React Query, and lucide icons. -- The approved UI must use the backend for authentication, tenant scoping, permissions, and persisted data ownership. -- Replacing the generated frontend with the approved UI plus a typed API client preserves the approved UX while keeping SaaS infrastructure centralized in the existing backend. - -## Migration Plan - -### 1. Frontend Replacement - -The generated `frontend/` application has been moved to `ref-frontend/`, and the approved UI has been moved into `frontend/`. - -Keep and adapt: - -- Tailwind/Radix/shadcn UI components. -- Customer-approved module layout and styling. -- Role-based module navigation. -- React Query-based data loading. -- Vite build pipeline unless deployment constraints require Next.js. - -Remove or replace: - -- Direct browser-to-database access from app code. -- Demo role switcher in production mode. -- Silent mock/static data behavior for data that must be persisted. -- Inline `console.error` handling in data functions. -- Directly embedded demo users and static campus assumptions where backend data should be authoritative. - -### 2. Frontend API Layer - -Create a dedicated frontend API layer that talks to `backend/`. - -Recommended structure: - -- `frontend/src/shared/api/httpClient.ts` -- `frontend/src/shared/api/auth.ts` -- `frontend/src/shared/api/campuses.ts` -- `frontend/src/shared/api/staff.ts` -- `frontend/src/shared/api/frameworks.ts` -- `frontend/src/shared/api/attendance.ts` -- `frontend/src/shared/api/messages.ts` -- `frontend/src/shared/api/documents.ts` - -Use aliases with `@` for all imports. Keep response and request types strict. Do not introduce `any` or casts. - -### 3. Backend Alignment - -Keep the generated backend and extend it where the approved UI has modules that do not map cleanly to existing entities. - -Existing backend coverage: - -- Multi-tenant base: `organizations` and organization links across generated entities. -- Campuses: `campuses`. -- Staff and users: `staff`, `users`, `roles`, `permissions`. -- Attendance: `attendance_sessions`, `attendance_records`. -- Communication: `messages`, `message_recipients`. -- Documents and policies: `documents`, `file`. -- Academic records: `classes`, `students`, `guardians`, `assessments`, `assessment_results`. -- Finance-related base: `fee_plans`, `invoices`, `payments`. - -Likely new backend modules needed: - -- FRAME weekly entries. -- Staff zone check-ins and progress. -- Safety quiz results. -- Personality quiz results. -- Walk-through check-ins. -- Campus attendance summaries/configuration if the existing attendance model is not enough. -- Content catalog for classroom strategies, signs, handbook/policies, community services, vocational opportunities, and ESA content if these need admin editing. - -### 4. Multi-Tenancy - -Use `organizations` as the tenant boundary. - -Required work: - -- Ensure every tenant-owned query is scoped by `currentUser.organizationId` or equivalent organization relation. -- Keep global access only for true platform/admin roles. -- Verify create/update/delete operations cannot cross tenant boundaries. -- Add tests for tenant isolation on high-risk entities: users, staff, campuses, students, attendance, messages, documents, and new customer UI modules. - -### 5. Auth And Roles - -Use backend JWT auth as the source of truth. - -Map approved UI roles to backend roles/permissions: - -- `teacher` -- `para` -- `office` -- `director` -- `superintendent` - -Required work: - -- Keep auth context backed by the backend JWT API. -- Load current user from `/api/auth/me`. -- Derive role and campus from backend user/staff profile. -- Enforce access both in frontend navigation and backend permissions. -- Keep UI route hiding as convenience only; backend authorization must be authoritative. - -### 6. Error Handling - -Backend: - -- Use centralized exception classes instead of ad hoc errors. -- Remove direct logging from business logic where centralized error handling should own observability. - -Frontend: - -- Replace silent returns such as `return []`, `return false`, or `return null` after failed API calls with explicit error states. -- Show user-facing errors using the existing toast/error UI. -- Keep native external-service errors from Gemini/OpenAI unchanged where applicable. - -### 7. Documentation - -For each migrated module: - -- Add or update backend docs in `backend/docs/`. -- Add frontend module docs in `frontend/docs/`. -- Document API contracts, permissions, tenant behavior, and known operational assumptions. - -## Implementation Order - -1. Make the Vite React application in `frontend/` build in place. -2. Add frontend shared constants and aliases. -3. Keep frontend auth integrated with backend JWT auth. -4. Add typed HTTP client and convert one vertical slice end-to-end, starting with campuses/staff/profile. -5. Convert role navigation to backend roles and permissions. -6. Convert FRAME weekly entries. -7. Convert attendance and walk-through modules. -8. Convert messaging, safety quiz results, personality quiz results, progress, and document/policy modules. -9. Add tenant isolation tests and API integration tests. -10. Remove `ref-frontend/` after the product frontend no longer needs the generated reference contracts. - -## Rejected Alternative - -Adapting the generated Next.js frontend in `ref-frontend/` to look and behave like the product frontend is not recommended. - -Reasons: - -- It preserves generated CRUD pages that are not the approved customer experience. -- It requires rebuilding the approved interaction model in a different UI stack. -- It keeps more generated code alive than needed. -- It increases the chance of divergence between the approved interface and the shipped product. - -## Open Questions - -- Should the production frontend remain Vite, or is Next.js required by hosting/deployment constraints? -- Which product frontend modules must be editable by admins versus shipped as static curated content? -- Which product modules should be implemented immediately after auth/profile and staff profile integration? -- What is the final role naming expected in user-facing copy? -- Which email flows are required for launch: invite, verification, password reset, parent communication, staff notifications? diff --git a/docs/full-integration-refactor-plan.md b/docs/full-integration-refactor-plan.md deleted file mode 100644 index e2267b7..0000000 --- a/docs/full-integration-refactor-plan.md +++ /dev/null @@ -1,622 +0,0 @@ -# Full Integration Refactor Plan - -## Purpose - -This document is the active cross-application backlog for integrating `frontend/`, `backend/`, and the PostgreSQL database. - -It must track only unfinished integration work and hard implementation rules. Completed migration history belongs in the feature-specific docs under `backend/docs/`, `frontend/docs/`, and supporting audit docs. - -When an item is completed, remove it from this plan and update the owning feature documentation instead of adding a completed checklist entry here. - -## Current Application Shape - -- `frontend/` is the only product frontend. -- `backend/` is the source of truth for auth, tenant ownership, roles, permissions, users, staff profiles, campuses, persisted workflows, content catalogs, email, files, and database access. -- `ref-frontend/` is a temporary generated reference. It is not runtime code and must be deleted after all needed endpoint-contract reference value is exhausted. - -## Non-Negotiable Rules - -- No frontend persisted workflow may use mock, sample, seed, or fallback records. -- No frontend runtime code may import backend seed files. -- Frontend runtime constants may contain only UI config, route/module metadata, query keys, timing values, style tokens, static UI labels, and intentionally static product copy. -- Backend seeds are for database seeding only. -- Frontend seed data is allowed only in tests or `frontend/src/test-seeds/`. -- Backend owns tenant scoping and permissions. Frontend route hiding is UX only. -- All frontend backend calls go through typed modules in `frontend/src/shared/api/`. -- New frontend workflows must follow `View -> Business Logic -> API/Data Access -> Backend`. -- View components must not import `frontend/src/shared/api/`. -- Business logic must not import view components. -- API modules must not import business or view code. -- All imports use the `@` alias. -- No `any`, unsafe casts, disabled TypeScript rules, disabled ESLint rules, compatibility bypasses, silent failures, or legacy re-export surfaces. -- Frontend auth must use backend-owned HttpOnly cookies only. Access and refresh tokens must never be stored in frontend browser storage. -- Secrets live only in backend `.env` or deployment environment variables. Frontend env values must be public browser-safe values only. -- New backend modules must include migration/model/service/route, tenant and role enforcement, docs, and focused verification. -- Every changed workflow must update the relevant docs before it is considered complete. - -## Current Verification Baseline - -Frontend current baseline: - -- `npm run typecheck` passes. -- `npm run lint` passes. -- `npm run test` passes with 51 files and 198 tests. -- `npm run build` passes. -- `npm run test:e2e` passes with 4 backend-free Playwright smoke tests. -- `npm run test:e2e:content` exists for backend-seeded content catalog integration tests. It requires backend migrations, backend seeders, and a running backend server. - -Backend current baseline: - -- Runtime code is 100% TypeScript + native ESM; the JS->TS / CJS->ESM migration is complete. -- `npm run typecheck` (`tsc --noEmit`) passes. -- `npm run lint` (`eslint .`) passes with no broad ignores. -- `npm test` (`node --test` via `tsx`) passes: 2 files, 15 tests (error-handler + import-boundaries). -- `npm run verify` (typecheck + lint + test) is the combined gate and is green. -- `npm run build` (`tsc` + `tsc-alias -f` + email-template asset copy) produces a runnable `dist/`. -- Migrations/seeders run via Umzug (`npm run db:migrate`, `npm run db:seed`); a run against the configured local database is still pending (Workstream 1). - -## Active Workstreams - -The workstreams are numbered in dependency/execution order and grouped into five phases. Earlier phases unblock later ones; within a phase, items may run in parallel unless a dependency is stated. Renumbered from the previous flat backlog: the former Role Model Decision, Product Onboarding Contract, and Permission-Based Frontend Authorization workstreams are now merged into Workstream 3 (RBAC); the former standalone seed-verification step is merged into Workstream 4. - -**Phase 1 — Foundation (everything else depends on a backend that boots, migrates, and serves routes).** - -### 1. Backend Migration And Runtime Boot - -Status: open. - -Problem: - -Backend product modules exist, but the configured local database migration/seed run has not been verified as a passing gate in this plan. - -Required work: - -1. Run `npm run db:migrate` against the configured local database. -2. Run `npm run db:seed` (current seed set; the final RBAC seed content is delivered in Workstream 4, which re-verifies this step). -3. Start backend with the documented env file. -4. Verify public content catalog routes, auth routes, and product module routes respond. -5. Record exact failing migration/seed/runtime errors if any. - -Acceptance criteria: - -- `npm run db:migrate` passes. -- `npm run db:seed` passes. -- Backend starts without generated default secrets. -- `GET /api/public/content-catalog/:contentType` works for the required seeded content catalog types. -- Any remaining backend runtime blocker is captured as a specific follow-up with file/error references. - -**Phase 2 — Identity, Tenancy & Access Core (the security spine: tenant isolation, the role/permission model, the seeded fixtures, and the finalized protected API surface). Workstreams 5–7 finalize what is public, what exists, and what is permission-gated, so they belong with the access model rather than after it.** - -### 2. Tenant Boundary Audit And Tests - -Status: open and high priority. Co-designed with Workstream 3: the campus/organization scoping here and the `campusId`-on-`users` model in §3.1 are the same scoping layer. - -Problem: - -Generated backend code has partial tenant scoping. Multi-tenant correctness cannot rely on frontend filtering. - -Required work: - -1. Audit all tenant-owned generated and product routes for organization scoping. -2. ~~Resolve inconsistent `organizationsId` versus `organizationId` usage~~ — done: unified on `organizationId` for all models (renamed the `users.organizationsId` column via migration `20260609000000-rename-users-organizationsid-to-organizationid`, updated db/api scoping, the auth DTO, and the frontend `CurrentUser` type). Verify the tenant-scoping `where` now correctly targets each model's `organizationId`. -3. Ensure create/update/delete paths cannot accept another tenant's organization or campus. -4. Ensure list/count/autocomplete endpoints are tenant-scoped. -5. Add backend tests proving cross-tenant records are not visible or mutable. - -Acceptance criteria: - -- Tenant isolation tests cover users, campuses, staff, students, attendance, messages, documents, and product module tables. -- A non-global user cannot read or mutate another tenant's data. -- Campus-scoped users cannot mutate another campus unless their backend permission explicitly allows it. - -### 3. RBAC: Role Hierarchy, Scoped Provisioning, And Guards - -Status: open and high priority. Umbrella workstream for the platform's user/role model. **Merged here:** the former Role Model Decision (role-model choice), the former Product Onboarding Contract (provisioning, §3.4), and the former Permission-Based Frontend Authorization (frontend guards, §3.6). Consumes the existing permission engine in `middlewares/check-permissions.ts`. - -Decisions recorded (owner-approved): - -- Role model: **first-class persisted roles** in the `roles` table, each carrying a `scope`, with preset permission sets and code-enforced relational constraints. The `GENERATED_ROLE_TO_PRODUCT_ROLE` / `STAFF_TYPE_TO_PRODUCT_ROLE` mappings and the 5-value `productRole` are retired. -- Frontend: **permission-based guards plus an authentication guard**; forbidden direct-URL access redirects to the 404 page; menu items the user cannot access are hidden. - -#### 3.0 Current state (analysis, verified) - -- Roles today (`backend/src/db/seeders/20200430130760-user-roles.ts`, `backend/src/shared/constants/roles.ts`): 7 generated roles (`Super Administrator`, `Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`, `Academic Coordinator`, `Finance Officer`) + `Public` + a `User` default-role name referenced in code. `globalAccess: true` is set only on `Super Administrator` and `Administrator`. These map down to 5 `productRole` values (`teacher | para | office | director | superintendent`). -- Authorization engine: `middlewares/check-permissions.ts` grants a request when (1) `currentUser.id === req.params.id || req.body?.id` (self-access), (2) a `custom_permissions` row matches, or (3) the effective role (assigned `app_role`, else cached `Public`) has the `${METHOD}_${ENTITY}` permission. CRUD routers wire `checkCrudPermissions(entity)`. This engine is sound and is kept. -- Permission catalog: CRUD × 27 entities + `READ_API_DOCS` + `CREATE_SEARCH`. **Product modules are not represented** (no permission exists for the FRAME, walkthrough, quizzes, director-dashboard, attendance-fill, ESA/vocational/community pages, classrooms, etc.). -- Multi-tenancy: `organizationId` on most models (unified, per Workstream 2). Campus link for a user is only indirect, via the `staff` row's `campusId`; `staff_type` is `teacher | admin | support`. There is **no `campusId` on `users`** and no first-class concept of director/owner/student/guardian. -- Seeders: **no organization row is seeded at all**, and the seeded campuses (`shared/constants/campuses.ts`, 6 rows) have **no `organizationId`**. `20200430130760` assigns roles by raw SQL: `super_admin@flatlogic.com`→SuperAdmin, `admin@flatlogic.com`→Administrator, `client@hello.com`→PlatformOwner, `john@doe.com`→TenantDirector. **This contradicts the product spec**, which states `admin@flatlogic.com` (`SEED_ADMIN_EMAIL`) is the super admin. No staff profiles are seeded. -- Frontend: `ModuleRouteGuard` + sidebar filter gate purely on the 5-value `productRole` (`shared/constants/appData.ts` `MODULES[].roles`, `shared/constants/moduleRoutes.ts`). `CurrentUser.permissions` exists but is unused. There is **no authentication guard** (an unauthenticated user reaching the shell is not redirected to `/login`), and `canUserRoleAccessModuleRoute` returns `true` for unknown paths (forbidden routes fall through to the catch-all rather than failing at the guard). Only `/login` and `*` (404) are public; there is no signup route. - -#### 3.1 Target role hierarchy and scope - -Eleven roles across five scopes (guest is the unauthenticated case, not a stored user): - -1. `super_admin` — scope `system`. Unlimited across the whole platform. -2. `system_admin` — scope `system`. Unlimited platform-wide **except** creating/deleting `super_admin` or other `system_admin`. -3. `owner` — scope `organization`. Unlimited inside its own company. -4. `superintendent` — scope `organization`. Unlimited inside the company **except** creating/deleting `owner` or other `superintendent`, and **except** deleting the company profile. -5. `director` — scope `campus`. Unlimited inside its assigned campus; creates campus users and classrooms; grants extra per-user permissions. -6. `office_manager` — scope `campus`. Reads all campus pages **except** the director dashboard; takes quizzes; leaves read receipts; may fill the attendance page. -7. `teacher` — scope `campus`. Reads all campus pages except the director dashboard. -8. `support_staff` — scope `campus`. Reads all campus pages except the director dashboard. (Replaces the current `para` value.) -9. `student` — scope `external`. Reads only `/community-partnerships`, `/vocational-opportunities`, `/esa-funding`. -10. `guardian` — scope `external`. Same external pages as `student`. -11. `guest` — unauthenticated. Public routes only (`/login`, `/signup`, error page); any other URL redirects to 404. - -Required work: - -1. Add a `scope` column to the `roles` model/migration: enum `system | organization | campus | external | guest`. Keep `globalAccess` for the two system roles (it already short-circuits tenant filtering in the CRUD layer). -2. Replace the seeded role set with these 11 (plus keep `Public` as the unauthenticated fallback mapped to `guest`). Write a data migration mapping legacy roles → new roles (`Super Administrator`→`super_admin`, `Administrator`→`system_admin`, `Platform Owner`→`owner`, `Tenant Director`→`superintendent`, `Campus Manager`→`director`, `Academic Coordinator`→`teacher`, `Finance Officer`→`office_manager`; `para`/`support` HR type → `support_staff`). -3. Retire `GENERATED_ROLE_TO_PRODUCT_ROLE`, `STAFF_TYPE_TO_PRODUCT_ROLE`, and `PRODUCT_ROLE_VALUES`; the role name + scope is now the source of truth. Replace `productRole` in the `/api/auth/me` DTO with `role: { name, scope }` (keep a stable string the frontend can switch on). -4. Add a campus link for campus-scoped users. Recommended: a nullable `campusId` on `users` (authorization scope), independent of the HR `staff` row. `organizationId` remains the tenant key; `campusId` is required for `director`/`office_manager`/`teacher`/`support_staff` and optional for `student`/`guardian`; null for `system`/`organization` scopes. (Same scoping layer as Workstream 2.) - -#### 3.2 Permission catalog expansion - -The frontend can only gate on permissions the backend actually checks, so every guarded page/action needs a permission name. - -1. Keep the CRUD × entity permissions; add `classrooms` (alias of `classes` if the same table, otherwise a new entity) to CRUD. -2. Add product-feature permissions for the module routes and special actions, named in the existing `${VERB}_${THING}` style — e.g. `READ_DIRECTOR_DASHBOARD`, `FILL_ATTENDANCE`, `TAKE_QUIZ`, `ACK_READ_RECEIPT`, `READ_COMMUNITY_PARTNERSHIPS`, `READ_VOCATIONAL_OPPORTUNITIES`, `READ_ESA_FUNDING`, plus one per remaining campus module page. -3. Define the **role → permission preset matrix** (the "predefined permissions per role" from the spec) and seed it into `rolesPermissionsPermissions`. Directors additionally hand out per-user `custom_permissions`. -4. Document the matrix in `backend/docs/auth-profile.md` (or a new `backend/docs/rbac.md`). - -#### 3.3 Relational role constraints (the "except …" rules) - -The constraints are about *who the target is*, not just *what verb on what entity*, so a flat `${METHOD}_${ENTITY}` permission cannot express them. Implement a policy layer enforced in the **service layer** (authoritative), with a middleware in front for early rejection: - -1. On user create/update/delete, compare actor scope+role against the target's role: `system_admin` cannot create/delete `super_admin` or `system_admin`; `superintendent` cannot create/delete `owner` or `superintendent`; campus roles cannot escalate above their scope; a director may only create/manage users within its own `campusId`. -2. On organization delete, require `super_admin`/`system_admin`/`owner` (a `superintendent` is blocked). -3. Cross-scope tenancy: every actor may only act within its `organizationId` (and `campusId` for campus scope) unless `globalAccess`. -4. Fix the self-access bypass security hole: `currentUser.id === req.body?.id` (`middlewares/check-permissions.ts`, the self-access branch) lets any user pass a guard by putting their own id in the request body; restrict self-access to read/update of the caller's own profile only. - -#### 3.4 Scoped provisioning / onboarding contract (former Product Onboarding Contract) - -Auto-provisioning chain, all backend-owned, each step sending an invitation email with a login link (`services/email/list/invitation.ts` already exists). No-go rules carried over from the former onboarding workstream: do not implement frontend self-registration; do not build profile creation/editing UI ahead of the backend contract; do not treat the generated `signup`/`profile` endpoints as the product contract; no temporary compatibility paths. - -1. `super_admin`/`system_admin` create an `owner` user → **auto-create the company (organization)** and link the owner; both start minimal (owner has only email+password; company has only the owner link). -2. `owner`/`superintendent` create superintendents, other org users, and campuses; assign a `director` to a campus. -3. `director` creates campus users (`teacher`, `support_staff`, `office_manager`, `student`, `guardian`) and classrooms, and grants extra `custom_permissions`. -4. Every created user receives a login-link email; on first login they land on their own editable profile. -5. Define which profile fields each role may self-edit vs. which require a higher role. - -#### 3.5 Backend guard coverage audit - -1. Confirm **every** route mounts authentication + `checkCrudPermissions`/`checkPermissions` (cross-reference Workstream 5 public-route audit so only intended routes are public). -2. Add the §3.3 relational policy to the user, organization, campus, staff, and classroom write paths. -3. Add the product-feature permission checks (§3.2) to the feature routes (FRAME, walkthrough, quizzes, attendance, communications, content-catalog, etc.). -4. Ensure list/count/autocomplete are tenant- and campus-scoped (ties to Workstream 2). - -#### 3.6 Frontend guards (former Permission-Based Frontend Authorization) - -Context: the backend already authorizes every request by **permission**, not role; the user DTO already carries `permissions: string[]`, but the frontend ignores it and gates on the 5-value `productRole`. The goal is to gate UI affordances (routes/menu items/buttons/request triggers) by **permission** — using the same `${METHOD}_${ENTITY}` names the backend checks — while the backend stays the sole source of truth (frontend gating is UX-only and must never be the only enforcement). - -1. **AuthGuard.** Wrap the shell: unauthenticated → redirect to `/login`; only `/login`, `/signup`, and the error page are reachable without a session. -2. **Expose permissions in the auth contract.** Confirm `GET /api/auth/me` and the sign-in response include the resolved effective `permissions: string[]` (role permissions ∪ `custom_permissions`); merge `custom_permissions` if not already; document the field in `backend/docs/auth-profile.md`. -3. **Shared permission vocabulary.** Add a typed catalog on the frontend (`frontend/src/shared/auth/permissions.ts`) mirroring backend `${METHOD}_${ENTITY}` names; keep it as `shared/constants`-style UI config; do not import backend code. -4. **Permission selector/hook.** Implement `hasPermission/hasAnyPermission/hasAllPermissions` in `frontend/src/business/auth/` (pure functions over `CurrentUser.permissions`) plus a `usePermissions()` hook. -5. **Gate routes by permission.** Replace `productRole`-based `canUserRoleAccessModuleRoute`/`getAccessibleModules` with permission checks; each `MODULES[]` entry declares the permission(s) it requires. Keep a role concept only where genuinely role-specific (e.g. dashboards), not for resource access. -6. **Forbidden direct-URL access redirects to the 404 page** (change `canUserRoleAccessModuleRoute`'s unknown-path `return true` and the guard's redirect target); inaccessible sidebar items are hidden. -7. **Encode the role-specific page sets.** `office_manager` sees all campus pages except the director dashboard (plus quiz/read-receipt/attendance-fill affordances); `teacher`/`support_staff` see all campus pages except the director dashboard; `student`/`guardian` see only `/community-partnerships`, `/vocational-opportunities`, `/esa-funding`; `director`/`superintendent`/`owner` see their full scope; `guest` sees only public routes. -8. **Gate affordances + handle 403.** Hide/disable create/edit/delete triggers by the matching permission (e.g. hide "Add campus" without `CREATE_CAMPUSES`); surface backend `forbidden` responses through one handler (toast + no crash) so UI/permission drift degrades gracefully. -9. **Roles/permissions admin UI.** Let an admin/director create a role, attach/detach permissions, and assign `custom_permissions` to a user — backed by the existing `roles`/`permissions`/`users` endpoints. -10. **Docs.** Update `frontend/docs/frontend-architecture.md` and `backend/docs/auth-profile.md` to describe the permission-based frontend model and the `${METHOD}_${ENTITY}` contract. - -#### 3.7 Tests - -1. Backend: relational-constraint tests (`system_admin` cannot delete `super_admin`; `superintendent` cannot delete `owner`/`superintendent` or the company; director scoped to its campus), tenant/campus isolation, and the provisioning chain (owner-create auto-creates company). -2. Frontend: per-role route-guard tests (each role can/cannot open the right pages), sidebar-visibility tests, `hasPermission`/selector unit tests, unauthenticated→`/login`, and forbidden-URL→404. -3. Backend-seeded authenticated e2e using the Workstream 4 fixtures (feeds Workstream 8) proving UI affordances match backend enforcement for at least one CRUD entity. - -Acceptance criteria: - -- The 11 roles exist as first-class scoped roles; the legacy mapping and 5-value `productRole` are gone. -- Backend is the sole authority: every route enforces authentication + permission + (where relational) the §3.3 policy; removing a frontend check never grants real access (backend still returns 403). -- The "except …" constraints hold in tests for `system_admin`, `superintendent`, and `director`. -- Frontend route/menu/affordance visibility is permission-driven; unauthenticated users are redirected to `/login`; forbidden direct URLs land on 404; guest sees only public routes. -- An admin can create a role, assign permissions, and the change is reflected in both backend enforcement and frontend UI for affected users. -- Owner-creation auto-creates the company; each provisioning step emails a login link. -- Backend and frontend `typecheck`/`lint`/`test` pass; the Workstream 4 seed runs clean and backs an authenticated e2e. - -### 4. RBAC Seed Data And Fixtures - -Status: open — depends on Workstream 3 (run after the role/permission/model changes land). Implements the spec's seed requirement: a preset company with one campus and staff covering every role, plus one test user per role. Also re-verifies the `npm run db:seed` gate from Workstream 1 against the final seed content. - -Required work: - -1. **Reconcile the admin identity with the spec.** Make `admin@flatlogic.com` (`SEED_ADMIN_EMAIL`) the **`super_admin`** (the spec's superadmin), not `Administrator`. Add a distinct `system_admin` seed user. Repurpose or delete the leftover template users (`john@doe.com`, `client@hello.com`, `super_admin@flatlogic.com`) so the seed set is exactly the role fixtures below. -2. **Seed one organization (company)** and **one campus** linked to it (give the existing `campuses` seed rows an `organizationId`, or seed a dedicated test campus). -3. **Seed 10 users, one per stored role** — `super_admin`, `system_admin`, `owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`, `student`, `guardian` — each with `app_roleId` set, `organizationId` set (except the two `system` users), and `campusId` set for the campus-scoped and external users as applicable. (`guest` = no user.) Passwords come from `SEED_ADMIN_PASSWORD`/`SEED_USER_PASSWORD` (no plaintext in the repo); test credentials live only in ignored local env or a dedicated test-seed config (per Workstream 8). -4. **Seed staff profiles** for the campus staff roles (`director`, `office_manager`, `teacher`, `support_staff`) so the campus has staff covering every staff role, each linked to the org, the campus, and its user. -5. Make the seed **idempotent** and reversible (clean `down`), and ordered after the role/permission seeder. -6. Re-verify the seeded data: one company, one campus under it, staff with all campus roles, and exactly one loginable user per role. Add a seed/integration check. - -Acceptance criteria: - -- `npm run db:seed` produces exactly one company, one campus under that company, staff covering every campus role, and one user per role with correct role/org/campus links. -- `admin@flatlogic.com` is the `super_admin` and can log in; each seeded role can log in and lands on the access surface defined in §3.6. -- No template leftover users remain; no plaintext credentials are committed. - -### 5. Public Backend Route Audit - -Status: open. Feeds Workstream 3 §3.5 (guard coverage): the guard audit can only assert "every non-public route is authenticated" once the public set is fixed. - -Problem: - -Public routes must be intentionally public. This includes public content catalog routes and any generated/template public integrations. - -Required work: - -1. List all routes without auth middleware. -2. Mark each as public-by-design or requiring auth. -3. Add auth where required. -4. Document intentionally public routes. - -Acceptance criteria: - -- Every unauthenticated backend route is documented. -- No tenant-owned data is exposed through accidental public routes. - -### 6. API Surface Coverage And Dead-Endpoint Decision - -Status: open (analysis complete; decision pending). Belongs with the access core: the permission catalog (§3.2) and guard audit (§3.5) must cover exactly the endpoints that survive this classification. - -Problem: - -The backend exposes the full Flatlogic-generated CRUD surface for all 39 models plus -template auth/file/search routes, but the product frontend only consumes a small set of -custom feature endpoints. The unused surface is dead code and attack surface; it must be -either pruned or intentionally wired. - -Method (how this was established): - -- Enumerated every backend route from `backend/src/routes/*` plus its mount prefix in `backend/src/index.ts`. -- Enumerated every frontend HTTP call. All frontend HTTP goes through `frontend/src/shared/api/*` over `httpClient` (`fetch`); there are no stray `fetch`/`axios`/`XMLHttpRequest` calls elsewhere, so the frontend call list is exhaustive. - -Findings: - -- **Frontend is fully wired:** every `shared/api` method targets a real, mounted backend endpoint. There are **0 orphan/broken frontend calls**. -- **Backend is only partially consumed:** of ~278 endpoints, the frontend uses **~35 (~13%)**; **~243 (~87%) have no frontend consumer.** -- The mismatch is one-directional: the frontend calls nothing extra; the backend carries a large unused layer. - -Consumed endpoints (the only wired surface — 35): - -- `auth` (4 of 16): `POST /api/auth/signin/local`, `GET /api/auth/me`, `POST /api/auth/refresh`, `POST /api/auth/signout`. -- `campuses` (1): `GET /api/public/campuses` (the authenticated `/api/campuses` CRUD is NOT used). -- `content-catalog` (6): `GET /api/public/content-catalog/:contentType`, `GET /api/content-catalog`, `GET /api/content-catalog/:contentType`, `POST /api/content-catalog`, `PUT /api/content-catalog/:contentType`, `DELETE /api/content-catalog/:contentType`. -- `campus_attendance` (4): `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`. -- `communications` (4): `GET /parent-messages`, `POST /parent-messages`, `GET /events`, `POST /events`. -- `frame_entries` (3): `GET /`, `POST /`, `PUT /:id`. -- `personality_quiz_results` (3): `GET /me`, `PUT /me`, `GET /distribution`. -- `safety_quiz_results` (2): `GET /`, `POST /`. -- `staff_attendance` (2): `GET /records`, `GET /summary`. -- `user_progress` (3): `GET /`, `POST /`, `DELETE /by-item`. -- `walkthrough_checkins` (3): `GET /`, `POST /`, `DELETE /:id`. - -Unused backend endpoints (no frontend consumer): - -1. **Generic CRUD template — 25 route groups × 9 endpoints = 225, none called:** - `academic_years`, `assessments`, `assessment_results`, `attendance_records`, - `attendance_sessions`, `campuses` (the `/api/campuses` CRUD), `classes`, - `class_enrollments`, `class_subjects`, `fee_plans`, `grades`, `guardians`, - `invoices`, `message_recipients`, `messages`, `organizations`, `payments`, - `permissions`, `roles`, `staff`, `students`, `subjects`, `timetable_periods`, - `timetables`, `users`. - Each exposes the identical shape: `POST /`, `POST /bulk-import`, `PUT /:id`, - `DELETE /:id`, `POST /deleteByIds`, `GET /`, `GET /count`, `GET /autocomplete`, - `GET /:id`. -2. **`auth` extras — 12, not called:** `POST /api/auth/signup`, `PUT /api/auth/profile`, - `PUT /api/auth/password-reset`, `PUT /api/auth/password-update`, - `POST /api/auth/send-password-reset-email`, - `POST /api/auth/send-email-address-verification-email`, - `PUT /api/auth/verify-email`, `GET /api/auth/email-configured`, - `GET /api/auth/signin/google` (+ `/callback`), `GET /api/auth/signin/microsoft` (+ `/callback`). -3. **`file` — 2, not called:** `GET /api/file/download`, `POST /api/file/upload/:table/:field` - (the frontend never uploads; `DocumentMutationDto` has no `file`). -4. **`search` — 1, not called:** `GET /api/search`. - -**Decision (owner, recorded):** the generic CRUD layer is **WIRE — kept, not -pruned.** These ~24 groups are not dead code; they will be used and integrated -with the frontend later (likely modeled on `ref-frontend`). No generic CRUD group -is to be removed. The `auth`/`file`/`search` extras (items 2–4 below) remain -individually decision-gated. - -Required work: - -1. Generic CRUD groups: **WIRE** (decided above) — build the frontend that uses - them (e.g. real `students`/`staff`/`guardians`/`invoices` management) and bring - each group up to the target backend architecture. Do **not** prune them. -2. `auth` extras: keep `signup`/password-reset/verify-email only if onboarding/recovery is - in scope (see Workstream 3 §3.4 provisioning); otherwise prune. OAuth callbacks tie to Workstream 15. -3. `file`: keep only if document/avatar upload is on the roadmap (ties to Workstream 7); - otherwise prune. -4. `search`: prune unless a search UI is planned. -5. After any prune, re-run backend `typecheck`/`lint`, and regenerate - `database-schema.md` only if models change. - -Cross-references: Workstream 7 (file upload), 5 (public route audit), 15 (OAuth), 3 (RBAC / permission-based authorization). - -Acceptance criteria: - -- Every backend endpoint is classified as **used**, **prune**, or **wire (planned)**, with no "unclassified" remainder. -- Pruned routes are removed cleanly (route + service + db/api + permission wiring) with `typecheck`/`lint` green and no dangling imports. -- The frontend remains fully wired (0 orphan calls) after changes. -- This document reflects the final surface. - -Performance hardening applied (owner chose **wire**, not prune, for the generic CRUD layer — it will back near-term management UIs modeled on `ref-frontend`): - -- `findBy` in all 24 generic-CRUD `db/api/*.ts` now loads its associations via a single `Promise.all` instead of sequential awaited getters (detail-endpoint latency drops from sum to max of the association queries). `users.findBy` was done earlier. -- List pagination now has shared defaults via `@/shared/constants/pagination` (`resolvePagination`, default page size 10 to match the `ref-frontend` grids, capped at 100). Applied to every generic-CRUD `findAll` and to the feature lists (`user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `frame_entries`, `content_catalog`, `communications` parent-messages/events, `campus_attendance` configs), which were reverted from `findAll` back to `findAndCountAll` so `count` is the true total for the pager. `staff_attendance /records` and `campus_attendance /summaries` keep their pre-existing per-endpoint limits. -- Fixed a latent CRUD tenant-scoping bug: routes and `db/api` create/update read `currentUser.organization?.id` (singular), but `findBy` only ever populates `organizations` (plural) + the `organizationId` scalar, so that read was always `undefined` (non-global creates silently set no organization). All 22 `db/api` + 24 route reads now use `currentUser.organizationId`; the dead 3-field fallback term was dropped from the 4 feature services; and the non-existent `organization?: { id }` field was removed from the `CurrentUser` type so the mistake cannot recur. -- The redundant existence-check `findBy` in the 22 CRUD `update` services was removed; they now rely on `DbApi.update` returning `null` (avoids loading every association just to validate existence). `remove` already had no pre-check. -- The per-request `UsersDBApi.findBy` (passport JWT strategy → `req.currentUser`, read by every guarded route) was collapsed from `findOne` + 4 parallel association getters + a `getPermissions()` into a **single** eager-loaded query (`findOne` + `include` of `app_role`+`permissions`, `staff_user`, `custom_permissions`, `organizations`). Its returned `app_role` now carries `permissions`, and `middlewares/check-permissions.ts` was reordered to read that eager-loaded `permissions` array before falling back to `getPermissions()` — removing the extra per-request permissions query. Same returned shape/fields as before (`AuthenticatedUser`); ~6–7 queries per request → 1. The cached `Public` role still works (its record carries `permissions`). -- `AuthService.currentUserProfile` (the `GET /me`, signin and refresh responses) now uses a dedicated `UsersDBApi.findProfileById` — a single eager-loaded query (`findByPk` + scoped `include`/`attributes`) returning only the columns and relations the profile DTO reads — instead of the heavy generic `findBy` (1 `findOne` + parallel association getters + `getPermissions`) plus a separate `getCampus`. Required idiomatic `NonAttribute` association declarations on the `Users`/`Roles`/`Staff` models so the include is type-safe without casts. The per-request passport `findBy` that populates `req.currentUser` is unchanged (it is the auth gate read by every guard); `/me` still performs that auth fetch plus this one lean profile query. - -### 7. File Upload And Download Permissions - -Status: open. Permission-gated surface; aligns with Workstream 3 §3.2/§3.5 and Workstream 6 (`file` extras). - -Problem: - -Backend file and document routes exist. Product file workflows need explicit permission verification before new upload/download UI is added. - -Required work: - -1. Audit upload permissions. -2. Audit download permissions. -3. Verify tenant and document ownership checks. -4. Add typed frontend upload client only for approved workflows. - -Acceptance criteria: - -- Unauthorized users cannot access another tenant's files. -- Upload/download behavior is documented and tested before new UI is added. - -**Phase 3 — Verification & Quality (locks in correctness once the access model and fixtures exist).** - -### 8. Backend-Seeded Authenticated E2E - -Status: depends on Workstream 4 fixtures and Workstream 3 guards. - -Problem: - -Current Playwright coverage includes backend-free smoke tests and backend-seeded content catalog tests. Authenticated persisted workflows need backend-seeded users, roles, campuses, staff profiles, and known credentials. - -Required prerequisites: - -1. Product onboarding/profile contract (Workstream 3 §3.4). -2. Backend-seeded auth fixtures (Workstream 4). -3. Tenant-scoped campus/staff fixtures (Workstream 4). -4. Stable test credentials stored only in ignored local env or dedicated test seed config. - -Required workflows after prerequisites: - -1. Login to dashboard. -2. Director creates or edits a FRAME entry and sees it after reload. -3. Staff completes QBS quiz and director/superintendent sees compliance. -4. Office enters campus attendance and superintendent sees aggregate. -5. Director submits walkthrough and sees summary update. -6. Staff marks a sign learned and progress persists after reload. - -Acceptance criteria: - -- Authenticated e2e tests use backend seeds, not frontend mock data. -- Tests do not require production secrets. -- Tests are documented and repeatable. - -### 9. API Documentation Hardening - -Status: open. Run after the route surface is finalized (Workstreams 3, 5, 6) so the docs describe the real endpoints. - -Problem: - -Markdown docs exist for migrated modules, but Swagger/OpenAPI coverage for product-specific and cookie-session endpoints is incomplete. - -Required work: - -1. Document `/api/auth/signin/local`. -2. Document `/api/auth/refresh`. -3. Document `/api/auth/signout`. -4. Document `/api/auth/me`. -5. Document product module endpoints that are not covered by generated Swagger output. -6. Document response and error shapes per endpoint. - -Acceptance criteria: - -- API docs match actual route payloads and response shapes. -- Cookie auth behavior is explicit. -- Frontend API contract tests remain aligned with docs. - -### 10. Accessibility Test Coverage - -Status: open. - -Required work: - -1. Add axe/Playwright accessibility checks for login. -2. Add axe/Playwright checks for dashboard. -3. Add checks for sidebar navigation. -4. Add checks for modal dialogs. -5. Add checks for forms with validation. -6. Add checks for tables/reports. - -Acceptance criteria: - -- Accessibility tests run in a documented command. -- Critical violations block completion of the refactor. - -**Phase 4 — Product Contracts (each gated by a customer/provider decision; independent of one another and parallelizable once the access core is in place).** - -### 11. Policy And Safety Acknowledgment Persistence - -Status: open pending product contract. - -Problem: - -Policy content is document-backed, but policy/protocol acknowledgments are not yet persisted. - -Required decisions: - -1. Which policies/protocols require acknowledgment. -2. Which roles must acknowledge. -3. Whether acknowledgments are per document version. -4. Who can report acknowledgment status. - -Required work after decision: - -1. Add acknowledgment backend model/migration/service/route. -2. Enforce tenant and role scope. -3. Add frontend typed API and business workflow. -4. Add report views only if required. - -Acceptance criteria: - -- Acknowledgments survive reload. -- Acknowledgment status is tenant-scoped. -- Unauthorized roles cannot view individual acknowledgment records. - -### 12. Attendance Source Contracts - -Status: partially open. - -Current state: - -- Campus attendance daily aggregate summaries are implemented for the current UI. -- Staff attendance snapshot/reporting is read-only. -- Student/class attendance source-of-truth workflow is not defined. - -Open decisions: - -1. Whether campus attendance aggregates are manually entered, imported, or derived from student attendance records. -2. Which external or internal source owns staff attendance writes/imports. -3. Whether student-level attendance UI is required. - -Required work after decision: - -1. Add write/import endpoints only after source contract exists. -2. Keep derived summaries server-side if summaries are derived. -3. Add backend tests for source-of-truth calculations. -4. Add frontend workflows only after backend contracts exist. - -Acceptance criteria: - -- Attendance source of truth is documented. -- UI values can be traced to backend records or server-side derivation. -- No frontend-only attendance source remains in persisted workflows. - -### 13. Generated Audio Provider Contract - -Status: open pending provider decision. - -Problem: - -Classroom timer uses built-in Web Audio sounds. AI-generated audio UI is intentionally not exposed because no backend audio provider contract exists. - -No-go rules: - -- Do not add generated audio UI. -- Do not call external audio providers from frontend runtime. -- Do not add frontend API keys or provider secrets. - -Required work after decision: - -1. Define backend provider contract. -2. Keep provider secrets in backend env. -3. Add backend service with native provider errors where required. -4. Add typed frontend API and explicit loading/error states. - -Acceptance criteria: - -- Generated audio is backend-mediated. -- No provider secret reaches the browser. -- Provider failures remain visible. - -**Phase 5 — Operations, Modernization & Cleanup (low coupling; can run any time after Phase 2, scheduled last to avoid mixing with the access-model changes).** - -### 14. Refresh Token Maintenance - -Status: open. - -Problem: - -Cookie auth and refresh rotation exist, but scheduled cleanup for expired/revoked refresh-token rows remains unresolved. - -Required work: - -1. Define refresh-token retention period. -2. Add cleanup job or operational command. -3. Ensure cleanup is observable. -4. Document operational usage. - -Acceptance criteria: - -- Expired and revoked refresh-token rows are cleaned after the approved retention window. -- Cleanup failures are visible and not silent. -- Auth behavior remains unchanged for valid sessions. - -### 15. OAuth Provider Strategy Modernization - -Status: open. Deferred until after the backend TypeScript/ESM migration (now complete), to keep auth-flow changes separate from the language/module migration. - -Problem: - -Social login uses `passport-google-oauth2` (0.2.0, last published 2022) and `passport-microsoft` (2.1.0). The Google strategy is low-maintenance and should be modernized, ideally consolidating both providers on a single maintained OAuth/OIDC library. - -Required work: - -1. Choose target: minimal swap to `passport-google-oauth20`, or consolidate both providers on `openid-client`. -2. Replace the Google (and optionally Microsoft) passport strategy in `backend/src/auth/auth.ts`. -3. Keep the existing cookie-based session and JWT issuance unchanged. -4. Verify callback URLs, scopes, and the social-signup user flow end to end. -5. Update auth docs (`backend/docs/auth-profile.md`, `cookie-auth.md`). - -Acceptance criteria: - -- Google and Microsoft sign-in work end to end with the chosen library. -- No deprecated/unmaintained OAuth strategy remains in dependencies. -- Cookie/JWT auth behavior is unchanged for existing sessions. -- This change is isolated from the language/module migration (separate PR/task). - -### 16. `ref-frontend/` Removal - -Status: open. - -Required work: - -1. Confirm no active docs require `ref-frontend/` for endpoint contract reference. -2. Confirm no scripts import or run `ref-frontend/`. -3. Delete `ref-frontend/`. -4. Update root docs and any setup docs. - -Acceptance criteria: - -- Only `frontend/` and `backend/` remain as active application code. -- No docs describe `ref-frontend/` as needed for normal development. - -## Phased Execution Order - -Run in workstream-number order unless the user explicitly reprioritizes. The phases are the dependency gates; within a phase, items may proceed in parallel. - -1. **Phase 1 — Foundation:** Workstream 1 (backend migrates, boots, serves routes). -2. **Phase 2 — Identity, Tenancy & Access Core:** Workstream 2 (tenant isolation) → Workstream 3 (RBAC: roles, permissions, constraints, provisioning, guards) → Workstream 4 (RBAC seed/fixtures), with Workstream 5 (public route audit), Workstream 6 (API surface classify/wire), and Workstream 7 (file permissions) finalizing the protected surface alongside Workstream 3. -3. **Phase 3 — Verification & Quality:** Workstream 8 (authenticated seeded e2e) → Workstream 9 (API docs) → Workstream 10 (accessibility). -4. **Phase 4 — Product Contracts (customer-gated, parallel):** Workstreams 11 (acknowledgments), 12 (attendance source), 13 (generated audio). -5. **Phase 5 — Operations, Modernization & Cleanup:** Workstreams 14 (refresh-token cleanup), 15 (OAuth modernization), 16 (`ref-frontend/` removal). - -## Definition Of Done - -The integration refactor is complete only when all of the following are true: - -- Backend migrations and seeders pass on the configured local database. -- Backend lint passes without broad ignores. -- Backend tenant isolation tests prove cross-tenant data is not visible or mutable. -- The 11-role scoped hierarchy (Workstream 3) is enforced backend-side, including the relational "except …" constraints for `system_admin`, `superintendent`, and `director`; the legacy generated-role mapping and 5-value `productRole` are removed. -- Backend role/permission tests cover product staff, director, and superintendent paths. -- Frontend gating is permission-driven with an authentication guard; unauthenticated users redirect to `/login`, forbidden direct URLs land on 404, and guest sees only public routes. -- The RBAC seed (Workstream 4) provisions one company, one campus with staff covering every campus role, and one loginable user per role, with `admin@flatlogic.com` as the super admin. -- Every unauthenticated backend route is documented as public-by-design. -- Product onboarding contract is implemented per Workstream 3 §3.4 or explicitly excluded from release scope. -- Every visible frontend workflow is backed by a typed backend API contract. -- No frontend runtime workflow depends on mock, sample, seed, or fallback records for persisted behavior. -- Frontend `typecheck`, `lint`, `test`, `build`, `test:e2e`, and documented seeded e2e suites pass in their required environments. -- Backend auth/API docs match actual route behavior. -- Required accessibility checks pass. -- `ref-frontend/` is deleted or a dated, explicit exception explains why it is still needed. diff --git a/frontend/docs/auth-integration.md b/frontend/docs/auth-integration.md index 9c492c1..7febefc 100644 --- a/frontend/docs/auth-integration.md +++ b/frontend/docs/auth-integration.md @@ -22,7 +22,7 @@ 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 `productRole`, `campus`, `staffProfile`, and `permissions`. +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`. @@ -34,7 +34,7 @@ The refresh flow keeps tokens backend-owned: 1. Backend sign-in sets short-lived access and long-lived refresh HttpOnly cookies. 2. Protected API requests use the access cookie only. 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`. +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. @@ -42,7 +42,7 @@ 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. Sign-in also remains available as a modal for in-app guest prompts. +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`. Rules: @@ -53,6 +53,15 @@ Rules: - Expired access plus valid refresh must restore the session without redirecting. - Expired access plus expired refresh must redirect to `/login` without showing a raw error. +## Authorization (frontend gating) + +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. +- **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). + ## Layering - View/provider: `frontend/src/contexts/AuthContext.tsx`, `frontend/src/components/frameworks/SignInModal.tsx`, and `frontend/src/components/sign-in-modal/` @@ -70,7 +79,7 @@ Rules: - Do not implement frontend registration or profile creation flows until the backend product contract is defined. - Do not treat generated auth signup/profile endpoints as the product onboarding contract. -- Track the cross-application onboarding task in `docs/full-integration-refactor-plan.md`. +- Track the cross-application onboarding task in `docs/backlog.md`. - New persisted workflows must use typed backend API modules and business-layer hooks. ## Standards diff --git a/frontend/docs/classroom-timer-integration.md b/frontend/docs/classroom-timer-integration.md index d5407d3..4e6c7d3 100644 --- a/frontend/docs/classroom-timer-integration.md +++ b/frontend/docs/classroom-timer-integration.md @@ -2,26 +2,64 @@ ## Purpose -The classroom timer uses local Web Audio API sounds. Generated timer audio is not exposed because the backend does not define an audio-generation provider contract. +A visual countdown timer with sensory backgrounds and a sound library. Sounds +come from three places, unified in one picker: hardcoded **built-in** synthesized +sounds, **generated** sounds (`recipe` rows), and **uploaded** audio +(`file`/`url` rows) from the `audio_files` library. ## Current Behavior -- `ClassroomTimer` renders visual timer controls, sensory backgrounds, preset/custom durations, fullscreen projection, and built-in timer sounds. -- `frontend/src/components/frameworks/ClassroomTimer.tsx` is a thin composition wrapper. -- Timer state, fullscreen coordination, custom time parsing, progress formatting, urgency color selection, particles, and Web Audio orchestration live in `frontend/src/business/classroom-timer/`. -- Timer catalogs are backend-owned content catalog records loaded through the shared content catalog API. `frontend/src/shared/constants/classroomTimer.ts` keeps only timing and particle-count configuration. -- Timer view pieces live under `frontend/src/components/classroom-timer/`. -- Built-in sounds are generated in-browser through the Web Audio API. -- Missing timer catalog data renders an explicit backend content error instead of falling back to frontend seed records. -- AI-generated timer sounds are not exposed in the UI until a backend audio provider contract exists. -- Remote audio must be added through a typed backend API module and business hook after the backend provider contract exists. +- `frontend/src/components/frameworks/ClassroomTimer.tsx` is a thin wrapper that + takes `userRole` (from the shell) and calls `useClassroomTimer(userRole)`. +- Timer state, fullscreen, custom-time parsing, progress/urgency, particles, and + Web Audio orchestration live in `frontend/src/business/classroom-timer/`. + `shared/constants/classroomTimer.ts` keeps timing/particle config plus the + sound-group labels and the generated/uploaded fallback icons. +- 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 + 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 + +The picker merges the built-ins with the `audio_files` library +(`business/audio-files/`): + +- **API/types**: `shared/api/audioFiles.ts`, `shared/types/audioFiles.ts` (incl. + 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). +- **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 + `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. + +## 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) ## Verification -- `npm run typecheck` passes. -- `npm run lint` passes without Fast Refresh warnings. -- `npm run test` passes. +- `npm run typecheck`, `npm run lint`, `npm run test` pass. ## Remaining Work -- Add a typed backend/API/business slice for generated audio only after the backend provider contract is defined. +- Swap the local `generateSoundRecipe` stub for a real model call once an AI key + exists. +- Binary `file` upload UI — needs a typed upload client and the file-download + ownership fix (see `backend/docs/audio-files.md`); `recipe`/`url` rows are + unaffected. diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index 3737195..de22751 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -44,7 +44,6 @@ Authenticated management: - personality quiz questions and personality type directory - personality workplace sidebar content - ESA funding content -- safety protocols - classroom timer backgrounds, sounds, presets, and tips - personality quiz intro feature cards diff --git a/frontend/docs/frontend-architecture.md b/frontend/docs/frontend-architecture.md index 25dec85..74de46e 100644 --- a/frontend/docs/frontend-architecture.md +++ b/frontend/docs/frontend-architecture.md @@ -190,8 +190,10 @@ 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`. -- App shell state, access selection, campus display lookup, mobile overlay visibility, shell outlet context, and prepared Sidebar/TopBar/GuestBanner/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, profile menu, and sign-in modal composition split under `frontend/src/components/top-bar/`. +- 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. +- 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. - Current-user progress under `frontend/src/business/user-progress/`, with typed API calls in `frontend/src/shared/api/userProgress.ts` for learned signs and zone check-ins. - Safety quiz results under `frontend/src/business/safety-quiz/`, with typed API calls in `frontend/src/shared/api/safetyQuizResults.ts`. @@ -200,8 +202,8 @@ The active frontend already has: - EI/personality results under `frontend/src/business/personality/`, with typed API calls in `frontend/src/shared/api/personality.ts`, DTO mappers, distribution selectors, workflow-specific hook files, and explicit loading/error states in the view. - Campus attendance config and daily summaries under `frontend/src/business/campus-attendance/`, with typed API calls in `frontend/src/shared/api/campusAttendance.ts`, DTO mappers, summary selectors, and explicit loading/error states in the view. - Staff attendance snapshot and director staff counts under `frontend/src/business/staff-attendance/`, with typed API calls in `frontend/src/shared/api/staffAttendance.ts`, DTO mappers, rollup selectors, and explicit loading/error states in the view. -- Handbook policies under `frontend/src/business/policies/`, with typed document API calls in `frontend/src/shared/api/documents.ts`, DTO mappers, selectors, and explicit loading/error states in the view. -- Classroom timer built-in sounds are local Web Audio behavior. AI-generated sounds are not exposed until a backend audio provider contract exists. +- Handbook policies and safety protocols under `frontend/src/business/policies/` and `frontend/src/business/safety-protocols/`, backed by the unified `policy_documents` store (`frontend/src/shared/api/policyDocuments.ts` + `policyAcknowledgments.ts`), with DTO mappers, selectors, persistent acknowledgment, and explicit loading/error states in the view. +- Classroom timer sounds are a unified library: hardcoded built-ins (local Web Audio), generated `recipe` rows, and uploaded `file`/`url` rows from `frontend/src/business/audio-files/`. Generation uses a local stub (`business/audio-files/generate.ts`) pending an AI key; playback branches by kind. - UI component variants live in dedicated non-component files such as `frontend/src/components/ui/button-variants.ts`, `badge-variants.ts`, `toggle-variants.ts`, and `navigation-menu-variants.ts`. - Loading, empty, and error state panels are centralized through `frontend/src/components/ui/state-panel.tsx`, with tone/size/alignment variants in `frontend/src/components/ui/state-panel-variants.ts`. - Repeated module headings use `frontend/src/components/ui/module-header.tsx`; simple native dropdowns use `frontend/src/components/ui/native-select.tsx`. @@ -210,8 +212,8 @@ The active frontend already has: - Frontend TypeScript runs in strict mode through `npm run typecheck`; `npm run build` runs typecheck before Vite. - Frontend unit tests run through `npm run test` with Vitest. Current coverage includes business-layer selectors and mappers for app-shell/sidebar, auth, campuses, campus attendance, classroom support, classroom timer, communications, community, dashboard, director dashboard, ESA funding, FRAME, personality, policies, safety quiz, sign language, staff attendance, top bar, user progress, vocational, walk-through check-in/summary/form workflows, and zones. - `npm run test` also enforces API/business/view import boundaries through `frontend/src/shared/architecture/import-boundaries.test.ts`. -- Frontend backend-free smoke tests run through `npm run test:e2e` with Playwright. Current smoke coverage verifies teacher, director, and superintendent guest navigation/access paths. -- Frontend backend-seeded content tests run through `npm run test:e2e:content` with Playwright after backend migrations, seeders, and the backend server are running. +- Frontend backend-free smoke tests run through `npm run test:e2e` with Playwright. +- Frontend backend-seeded tests run through `npm run test:e2e:content` with Playwright after backend migrations, seeders, and the backend server are running — covering the content catalog and authenticated **RBAC access** (`tests/e2e/rbac-access.seeded.e2e.ts`: per-role route access plus API enforcement of the permission/relational policy, using the seeded fixture users). - Frontend dependency verification is clean: `npm run lint`, `npm run test`, `npm run build`, `npm audit --audit-level=low`, and `npm outdated` pass for stable package releases. ## Known Remaining Gaps @@ -219,4 +221,4 @@ The active frontend already has: - New or changed framework wrappers should follow the same thin-view plus business-hook pattern. - New product routes should be added to module route metadata, `frontend/src/app/appRoutes.tsx`, and covered by route metadata tests. - TypeScript compiler strictness is enabled for the current baseline. Keep future slices compatible with `strict`, `noUnusedLocals`, and `noUnusedParameters`. -- Unit test coverage exists for route config, module route metadata, API/data-access behavior, auth refresh/retry behavior, business-layer selector/mapper/report slices, and import-boundary guardrails. Guest-role Playwright smoke tests cover the current backend-free staff/director/superintendent paths. +- Unit test coverage exists for route config, module route metadata (incl. role-aware landing), permission selectors, API/data-access behavior, auth refresh/retry behavior, business-layer selector/mapper/report slices, and import-boundary guardrails. Backend-seeded Playwright tests cover authenticated per-role RBAC access and API enforcement. diff --git a/frontend/docs/index.md b/frontend/docs/index.md index f67aa62..7186091 100644 --- a/frontend/docs/index.md +++ b/frontend/docs/index.md @@ -2,10 +2,8 @@ ## Start Here -- Repository working rules: [`../../AGENTS.md`](../../AGENTS.md) +- Repository working rules: [`../../CLAUDE.md`](../../CLAUDE.md) - Frontend architecture: [`frontend-architecture.md`](frontend-architecture.md) -- Frontend architecture review: [`frontend-architecture-review.md`](frontend-architecture-review.md) -- Remaining frontend issues plan: [`remaining-frontend-issues-fix-plan.md`](remaining-frontend-issues-fix-plan.md) - Object router rules: [`object-router.md`](object-router.md) - Error handling: [`error-handling.md`](error-handling.md) - Test coverage rules: [`test-coverage.md`](test-coverage.md) @@ -15,8 +13,6 @@ Read the repository rules first, then use the frontend architecture document as ## Architecture And Shared Foundations - [`frontend-architecture.md`](frontend-architecture.md): three-layer frontend architecture, import direction, routing, and update rules. -- [`frontend-architecture-review.md`](frontend-architecture-review.md): latest frontend architecture and implementation quality review. -- [`remaining-frontend-issues-fix-plan.md`](remaining-frontend-issues-fix-plan.md): phased plan for closing remaining frontend issues. - [`object-router.md`](object-router.md): React Router object-route configuration. - [`ui-kit.md`](ui-kit.md): shared view-layer primitives and consolidation rules. - [`theme.md`](theme.md): centralized theme constants and CSS token ownership. @@ -34,7 +30,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. -- [`top-bar-integration.md`](top-bar-integration.md): top bar search, notifications, profile menu, and sign-in modal. +- [`top-bar-integration.md`](top-bar-integration.md): top bar search, notifications, and profile menu. ## Product Slices diff --git a/frontend/docs/personality-integration.md b/frontend/docs/personality-integration.md index dcfb523..39d7bdc 100644 --- a/frontend/docs/personality-integration.md +++ b/frontend/docs/personality-integration.md @@ -36,8 +36,7 @@ Business logic layer: - `frontend/src/business/personality/mappers.ts` - `frontend/src/business/personality/selectors.ts` - `frontend/src/business/personality/types.ts` -- `frontend/src/shared/constants/emotionalIntelligence.ts` -- `frontend/src/shared/types/emotionalIntelligence.ts` +- `frontend/src/shared/types/emotionalIntelligence.ts` (EI content itself is backend-owned via the content catalog) API/data access layer: diff --git a/frontend/docs/policies-integration.md b/frontend/docs/policies-integration.md index 29eae34..8f2c182 100644 --- a/frontend/docs/policies-integration.md +++ b/frontend/docs/policies-integration.md @@ -1,51 +1,75 @@ -# Policies Integration +# Policies & Safety Protocols Integration ## Purpose -The handbook and policies workflow reads and mutates policy documents through the backend `documents` API. +Two pages — **Handbook & Policies** and **Safety Protocols** — 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 +Operations/Behavior/… and the safety card icon). Staff acknowledgment is +**persisted per document version** via `policy_acknowledgments`. ## Frontend Structure +Handbook & Policies: + - Framework wrapper: `frontend/src/components/frameworks/HandbookPolicy.tsx` -- Focused view components: `frontend/src/components/policies/` -- Business hooks: `frontend/src/business/policies/hooks.ts` -- Business page workflow hook: `frontend/src/business/policies/pageHooks.ts` -- Business mappers: `frontend/src/business/policies/mappers.ts` -- Business selectors: `frontend/src/business/policies/selectors.ts` -- API layer: `frontend/src/shared/api/documents.ts` -- DTO types: `frontend/src/shared/types/documents.ts` -- Policy types/constants: `frontend/src/shared/types/policies.ts`, `frontend/src/shared/constants/policies.ts` +- View components: `frontend/src/components/policies/` +- Business: `frontend/src/business/policies/{hooks,pageHooks,mappers,selectors}.ts` + +Safety Protocols: + +- Module: `frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx` + (+ `SafetyProtocolForm.tsx`, `SafetyDynamicListEditor.tsx`) +- Business: `frontend/src/business/safety-protocols/{hooks,mappers,selectors,types}.ts` + +Shared: + +- API layer: `frontend/src/shared/api/policyDocuments.ts`, + `frontend/src/shared/api/policyAcknowledgments.ts` +- DTO types: `frontend/src/shared/types/policyDocuments.ts` +- Types/constants: `frontend/src/shared/types/policies.ts`, + `frontend/src/shared/constants/policies.ts`, + `frontend/src/shared/constants/safetyProtocols.ts` ## Backend Contract -The slice uses the existing backend route: +- `GET /api/policy_documents?category=` +- `POST /api/policy_documents`, `PUT /api/policy_documents/:id`, + `DELETE /api/policy_documents/:id` +- `GET /api/policy_acknowledgments` (caller's own), `POST /api/policy_acknowledgments` + (`{ data: { policyDocumentId } }` → acknowledges the current version) -- `GET /api/documents?category=policy` -- `POST /api/documents` -- `PUT /api/documents/:id` -- `DELETE /api/documents/:id` - -Policy records are represented as `documents` rows: - -- `category`: fixed to `policy` -- `entity_type`: fixed to `organization` -- `entity_reference`: policy category displayed in the handbook -- `name`: policy title -- `notes`: policy content -- `uploaded_at`: recorded mutation timestamp +A `policy_documents` row carries: `title`, `body`, `category`, `tag`, `author` +(display name of the creating user, server-set), `steps` + +`autism_considerations` (JSONB string arrays — author-filled structured content +for safety protocols), `version` (bumped on title/body/steps/considerations +change), `active`, tenant `organizationId` + nullable `campusId`. ## Behavior -- `HandbookPolicy` is a thin wrapper that calls `usePoliciesPage` and renders `PoliciesView`. -- Policy UI is split into focused components for the hero, create form, filters, status panels, list, cards, and empty state. -- Policy forms and filters use shared UI primitives: `Button`, `Input`, `Textarea`, `NativeSelect`, and `StatePanel`. -- Policy list/create/update/delete flows use React Query hooks and backend error states. -- Director and superintendent roles can manage policies in the frontend. -- Acknowledgement state remains session-local because the backend does not yet expose a policy acknowledgement contract. +- **Handbook**: `HandbookPolicy` calls `usePoliciesPage` and renders + `PoliciesView` (hero, create/edit form, filters, list, cards, empty state). + Sub-category maps to/from `tag`. Full add/edit/delete. +- **Safety Protocols**: `SafetyProtocolsModule` lists `safety_protocol` docs, + rendering author-filled `steps` + autism considerations with a static per-`tag` + icon. Managers get a FRAME-style authoring flow (header *New Protocol* → + `SafetyProtocolForm`, per-card *Edit*/*Delete*) with **dynamic** steps + + considerations rows (`SafetyDynamicListEditor`, add/remove per protocol). +- **Acknowledgment is persisted** (`usePolicyAcknowledgments` / + `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. +- Both pages are seeded from `20260611050000-policy-documents-seed.ts`. + +## Tests + +- `business/policies/mappers.test.ts`, `business/policies/selectors.test.ts` +- `business/safety-protocols/mappers.test.ts`, + `business/safety-protocols/selectors.test.ts` ## Verification -- `npm run build` passes. -- `npm run lint` passes for the current frontend baseline. -- `npm run typecheck` passes. -- `npm run test` passes. +- `npm run typecheck`, `npm run lint`, `npm run test` pass. diff --git a/frontend/docs/shared-app-types.md b/frontend/docs/shared-app-types.md index abaaeb9..bc979c8 100644 --- a/frontend/docs/shared-app-types.md +++ b/frontend/docs/shared-app-types.md @@ -17,7 +17,7 @@ UI-facing product types live in `frontend/src/shared/types/app.ts`. - `ZoneColor` - cross-module static catalog item types used by the current UI -Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, and `documents.ts`. +Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, `policyDocuments.ts`, and `audioFiles.ts`. ## Rules diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index ebaebbf..29caf9a 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -18,7 +18,9 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/app/appRoutes.test.ts` - `frontend/src/business/app-shell/selectors.test.ts` +- `frontend/src/business/auth/hooks.test.tsx` - `frontend/src/business/auth/mappers.test.ts` +- `frontend/src/business/auth/permissions.test.ts` - `frontend/src/business/auth/selectors.test.ts` - `frontend/src/business/campus-attendance/mappers.test.ts` - `frontend/src/business/campus-attendance/printReport.test.ts` @@ -33,10 +35,14 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/business/esa-funding/selectors.test.ts` - `frontend/src/business/frame/mappers.test.ts` - `frontend/src/business/frame/selectors.test.ts` +- `frontend/src/business/audio-files/selectors.test.ts` +- `frontend/src/business/audio-files/generate.test.ts` - `frontend/src/business/personality/mappers.test.ts` - `frontend/src/business/personality/selectors.test.ts` - `frontend/src/business/policies/mappers.test.ts` - `frontend/src/business/policies/selectors.test.ts` +- `frontend/src/business/safety-protocols/mappers.test.ts` +- `frontend/src/business/safety-protocols/selectors.test.ts` - `frontend/src/business/safety-quiz/mappers.test.ts` - `frontend/src/business/safety-quiz/selectors.test.ts` - `frontend/src/business/sign-language/selectors.test.ts` @@ -54,7 +60,6 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/shared/api/campuses.test.ts` - `frontend/src/shared/api/communications.test.ts` - `frontend/src/shared/api/contentCatalog.test.ts` -- `frontend/src/shared/api/documents.test.ts` - `frontend/src/shared/api/frame.test.ts` - `frontend/src/shared/api/httpClient.test.ts` - `frontend/src/shared/api/personality.test.ts` @@ -67,8 +72,12 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/shared/business/queryState.test.ts` - `frontend/src/shared/constants/moduleRoutes.test.ts` - `frontend/src/shared/errors/errorMessages.test.ts` +- `frontend/src/hooks/usePermissions.test.tsx` +- `frontend/src/components/sign-in-modal/SignInForm.test.tsx` -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 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. +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, 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 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. @@ -82,29 +91,23 @@ The import-boundary tests enforce the three-layer dependency direction: - Runtime data access must stay centralized in the shared API layer. - Every runtime module in `frontend/src/shared/api/` must have a colocated `.test.ts` contract test. -## Current Smoke Coverage +## Backend-Seeded E2E Coverage -Playwright smoke tests live under: - -- `frontend/tests/e2e/app-shell.e2e.ts` - -The current smoke suite covers: - -- teacher guest access to staff modules and absence of director-only modules; -- restricted executive route redirect for teacher guests; -- director guest access to director and walk-through navigation; -- superintendent guest access persistence after navigating between executive modules. - -## Backend-Seeded Content E2E Coverage - -Backend-seeded content tests live under: +Backend-seeded E2E tests live under: - `frontend/tests/e2e/content-catalog.seeded.e2e.ts` +- `frontend/tests/e2e/accessibility.seeded.e2e.ts` +- `frontend/tests/e2e/rbac-access.seeded.e2e.ts` +- `frontend/tests/e2e/tenant-isolation.seeded.e2e.ts` +- `frontend/tests/e2e/provisioning.seeded.e2e.ts` +- `frontend/tests/e2e/product-workflow.seeded.e2e.ts` +- `frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts` +- `frontend/tests/e2e/audio-files.seeded.e2e.ts` The seeded suite is intentionally excluded from default `npm run test:e2e` through `frontend/playwright.config.ts`. Run it with: ```bash -VITE_BACKEND_API_URL=http://localhost:8080/api npm run test:e2e:content +npm run test:e2e:content ``` Prerequisites: @@ -113,7 +116,57 @@ Prerequisites: - backend database seeders have run; - backend server is running at `VITE_BACKEND_API_URL`. -The seeded suite verifies the minimum content catalog seed set through the public backend API, then renders classroom timer, classroom support, sign language, and zones routes with UI assertions based on the live backend response. +Test credentials are hardcoded (see `CLAUDE.md` for the full list): +- Admin: `admin@flatlogic.com` / `flatlogicAdmin123!` +- Users: `@flatlogic.com` / `flatlogicUser123!` + +The seeded suite verifies: +- Minimum content catalog seed set through the public backend API +- Classroom timer, classroom support, sign language, and zones routes with UI assertions based on live backend response +- RBAC access control for different user roles (teacher, director, superintendent access to appropriate routes) +- **Tenant isolation**: Users from one organization cannot read, list, update, or delete records from another organization +- **Scoped provisioning**: Creating an owner auto-creates and links a new company +- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly +- **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 + +## Accessibility E2E Coverage + +Accessibility tests use `@axe-core/playwright` to verify WCAG 2 AA compliance across all 19 application pages. Run them with: + +```bash +npm run test:e2e:content -- --grep "accessibility" +``` + +The accessibility suite covers: + +- Login page (unauthenticated) +- Dashboard +- Classroom Timer +- Classroom Support +- Sign Language +- Zones of Regulation +- F.R.A.M.E. Weekly +- Behavior Management +- Emotional Intelligence +- Attendance +- Parent Communication +- Internal Alerts +- Safety Protocols +- Handbook & Policies +- Community & Partnerships +- Vocational Opportunities +- ESA Funding Info +- Walk-Through Check-In +- Director Dashboard + +Each test runs axe-core with WCAG 2 A and AA tags (`wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`) and fails on any violation. This ensures: + +- Sufficient color contrast ratios (4.5:1 for normal text, 3:1 for large text) +- No nested interactive elements (buttons inside buttons) +- Proper ARIA labels and roles +- Keyboard accessibility +- Screen reader compatibility ## Rules For New Tests @@ -123,5 +176,7 @@ The seeded suite verifies the minimum content catalog seed set through the publi - Keep tests deterministic: no live backend, no network, and no current-time dependency unless the current date is injected. - Use typed fixtures instead of `any` or unsafe casts. - Add React/hook tests only when a workflow cannot be verified through pure functions. +- Use `renderHook` from `@testing-library/react` for hook tests; mock `useAuth` context when testing permission hooks. +- Use `render` from `@testing-library/react` and `userEvent` for component interaction tests. - Keep backend-free Playwright smoke tests focused on high-value role/navigation workflows. - Put live backend/database assertions only in explicit seeded suites such as `test:e2e:content`. diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index 0e946ff..2815311 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -2,7 +2,7 @@ ## Purpose -`TopBar` renders app-shell search, auth entry, campus/role badges, notifications, and profile menu through the three-layer frontend architecture. +`TopBar` renders app-shell search, campus/role badges, notifications, and the profile menu (which delegates sign-out to the auth session) through the three-layer frontend architecture. ```text View -> Business Logic -> API/Data Access -> Backend @@ -20,7 +20,6 @@ View: - `frontend/src/components/top-bar/TopBarBadges.tsx` - `frontend/src/components/top-bar/TopBarNotifications.tsx` - `frontend/src/components/top-bar/TopBarProfileMenu.tsx` -- `frontend/src/components/top-bar/TopBarSignInModal.tsx` Business logic: @@ -35,7 +34,7 @@ Shared config: ## Behavior - `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`. -- `useTopBarPage` owns profile menu state, notifications menu state, sign-in modal state, search query state, and sign-out error state. +- `useTopBarPage` owns profile menu state, notifications menu state, search query state, and sign-out error state. - Selectors handle initials, campus label fallback, shared role labels, and unread notification count. - 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8896c1..2ac638b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,10 +62,14 @@ "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/typography": "^0.5.20", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -75,14 +79,25 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.60.1", "vite": "^8.0.16", "vitest": "^4.1.8" + }, + "engines": { + "node": ">=24" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -96,6 +111,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -288,6 +367,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -336,6 +425,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz", + "integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", @@ -504,6 +746,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2673,6 +2933,96 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2684,6 +3034,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3217,6 +3575,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3229,6 +3612,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3276,6 +3669,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3299,6 +3702,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -3449,6 +3862,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3590,6 +4024,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -3618,6 +4066,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3631,6 +4086,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3647,6 +4112,14 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.368", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", @@ -3696,6 +4169,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -4141,6 +4627,19 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4171,6 +4670,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -4213,6 +4722,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4237,6 +4753,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4604,6 +5171,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4626,6 +5204,23 @@ "node": ">= 20" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4759,6 +5354,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4913,6 +5521,30 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5163,6 +5795,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5178,6 +5824,16 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -5218,6 +5874,19 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5304,6 +5973,26 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -5393,6 +6082,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5463,6 +6198,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -5813,6 +6558,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5856,6 +6649,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index aad6dbb..6a05db1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,10 +73,14 @@ "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/typography": "^0.5.20", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -86,6 +90,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", diff --git a/frontend/playwright.content.config.ts b/frontend/playwright.content.config.ts index 8ae5cd3..7aceb51 100644 --- a/frontend/playwright.content.config.ts +++ b/frontend/playwright.content.config.ts @@ -11,9 +11,12 @@ export default defineConfig({ retries: 0, reporter: 'list', use: { - baseURL: 'http://127.0.0.1:4174', + baseURL: 'http://localhost:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', + extraHTTPHeaders: { + Origin: 'http://localhost:3000', + }, }, projects: [ { @@ -22,8 +25,8 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174 --strictPort', - url: 'http://127.0.0.1:4174', + command: 'npm run build && npm run preview -- --host localhost --port 3000 --strictPort', + url: 'http://localhost:3000', reuseExistingServer: true, timeout: 120_000, }, diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index e60b2c9..c5d47ef 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -1,14 +1,35 @@ import type { ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + QueryClient, + QueryClientProvider, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; +import { toast } from 'sonner'; import { Toaster } from '@/components/ui/toaster'; 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 { APP_DEFAULT_THEME } from '@/shared/constants/theme'; +import { + FORBIDDEN_ERROR_MESSAGE, + isForbiddenError, +} from '@/shared/errors/errorMessages'; -const queryClient = new QueryClient(); +// Single handler so a backend 403 (permission denied) degrades to a toast +// rather than a crash or silent failure, wherever it occurs. +function notifyOnForbidden(error: unknown): void { + if (isForbiddenError(error)) { + toast.error(FORBIDDEN_ERROR_MESSAGE); + } +} + +const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError: notifyOnForbidden }), + mutationCache: new MutationCache({ onError: notifyOnForbidden }), +}); interface AppProvidersProps { readonly children: ReactNode; diff --git a/frontend/src/app/AuthGuard.tsx b/frontend/src/app/AuthGuard.tsx new file mode 100644 index 0000000..0f7e627 --- /dev/null +++ b/frontend/src/app/AuthGuard.tsx @@ -0,0 +1,20 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '@/contexts/useAuth'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; + +/** + * 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. + */ +export function AuthGuard() { + const { isAuthenticated, loading } = useAuth(); + + if (!loading && !isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/app/IndexRedirect.tsx b/frontend/src/app/IndexRedirect.tsx new file mode 100644 index 0000000..b78d4ee --- /dev/null +++ b/frontend/src/app/IndexRedirect.tsx @@ -0,0 +1,15 @@ +import { Navigate } from 'react-router-dom'; +import { useShellOutletContext } from '@/app/shellOutletContext'; +import { getDefaultRoutePathForRole } 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. + */ +export function IndexRedirect() { + const shell = useShellOutletContext(); + + return ( + + ); +} diff --git a/frontend/src/app/ModuleRouteGuard.tsx b/frontend/src/app/ModuleRouteGuard.tsx index abcb133..a6e0ee1 100644 --- a/frontend/src/app/ModuleRouteGuard.tsx +++ b/frontend/src/app/ModuleRouteGuard.tsx @@ -1,12 +1,10 @@ import type { ReactNode } from 'react'; import { Suspense } from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { useShellOutletContext } from '@/app/shellOutletContext'; import { StatePanel } from '@/components/ui/state-panel'; -import { - canUserRoleAccessModuleRoute, - DEFAULT_MODULE_ROUTE_PATH, -} from '@/shared/constants/moduleRoutes'; +import { canUserRoleAccessModuleRoute } from '@/shared/constants/moduleRoutes'; +import NotFound from '@/pages/NotFound'; interface ModuleRouteGuardProps { readonly children: ReactNode; @@ -16,8 +14,9 @@ export function ModuleRouteGuard({ children }: ModuleRouteGuardProps) { const location = useLocation(); const shell = useShellOutletContext(); + // Forbidden direct-URL access lands on the 404 page (not a silent redirect). if (!canUserRoleAccessModuleRoute(location.pathname, shell.userRole)) { - return ; + return ; } return ( diff --git a/frontend/src/app/appRoutes.test.ts b/frontend/src/app/appRoutes.test.ts index 0be3486..9bf5413 100644 --- a/frontend/src/app/appRoutes.test.ts +++ b/frontend/src/app/appRoutes.test.ts @@ -6,14 +6,18 @@ import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; describe('app routes', () => { it('declares the required top-level routes through object config', () => { expect(appRoutes.map((route) => route.path)).toEqual([ - APP_ROUTE_PATHS.home, + undefined, // AuthGuard wrapper (no path) APP_ROUTE_PATHS.login, APP_ROUTE_PATHS.notFound, ]); }); it('declares one shell child route for every product module route', () => { - const shellRoute = appRoutes.find((route) => route.path === APP_ROUTE_PATHS.home); + // AuthGuard is the first route, shell is nested inside it + const authGuardRoute = appRoutes[0]; + const shellRoute = authGuardRoute?.children?.find( + (route) => route.path === APP_ROUTE_PATHS.home, + ); const childPaths = shellRoute?.children ?.map((route) => route.path) .filter((path): path is string => typeof path === 'string'); @@ -22,7 +26,10 @@ describe('app routes', () => { }); it('redirects the shell index route to the dashboard route', () => { - const shellRoute = appRoutes.find((route) => route.path === APP_ROUTE_PATHS.home); + const authGuardRoute = appRoutes[0]; + const shellRoute = authGuardRoute?.children?.find( + (route) => route.path === APP_ROUTE_PATHS.home, + ); const indexRoute = shellRoute?.children?.find((route) => route.index); expect(indexRoute).toBeDefined(); diff --git a/frontend/src/app/appRoutes.tsx b/frontend/src/app/appRoutes.tsx index d03c0b3..db939d0 100644 --- a/frontend/src/app/appRoutes.tsx +++ b/frontend/src/app/appRoutes.tsx @@ -1,4 +1,3 @@ -import { Navigate } from 'react-router-dom'; import type { ReactNode } from 'react'; import type { RouteObject } from 'react-router-dom'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; @@ -23,6 +22,8 @@ import { ZonesOfRegulationPage, } from '@/app/lazyModulePages'; import { ModuleRouteGuard } from '@/app/ModuleRouteGuard'; +import { AuthGuard } from '@/app/AuthGuard'; +import { IndexRedirect } from '@/app/IndexRedirect'; import AppLayout from '@/components/AppLayout'; import Login from '@/pages/Login'; import NotFound from '@/pages/NotFound'; @@ -33,13 +34,16 @@ function moduleRoute(element: ReactNode): ReactNode { export const appRoutes: RouteObject[] = [ { - path: APP_ROUTE_PATHS.home, - element: , + element: , children: [ { - index: true, - element: , - }, + path: APP_ROUTE_PATHS.home, + element: , + children: [ + { + index: true, + element: , + }, { path: APP_ROUTE_PATHS.dashboard.slice(1), element: moduleRoute(), @@ -112,6 +116,8 @@ export const appRoutes: RouteObject[] = [ path: APP_ROUTE_PATHS.director.slice(1), element: moduleRoute(), }, + ], + }, ], }, { diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts index 5f7059e..f4ce838 100644 --- a/frontend/src/business/app-shell/hooks.ts +++ b/frontend/src/business/app-shell/hooks.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { MODULES } from '@/shared/constants/appData'; import { @@ -9,12 +9,8 @@ import { getModuleIdByRoutePath, getModuleRoutePath, } from '@/shared/constants/moduleRoutes'; +import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; import type { ModuleId, UserRole } from '@/shared/types/app'; -import { - DEFAULT_GUEST_PREVIEW_ROLE, - GUEST_PREVIEW_USER_NAME, - GUEST_PREVIEW_ROLE_OPTIONS, -} from '@/shared/constants/guestPreviewRoles'; import { getAccessibleModuleId, getAccessibleModules, @@ -30,16 +26,16 @@ import type { UseAppShellOptions, } from '@/business/app-shell/types'; -function getUserRole(options: UseAppShellOptions, guestPreviewRole: UserRole): UserRole { - return options.isAuthenticated && options.profile?.role ? options.profile.role : guestPreviewRole; +function getUserRole(options: UseAppShellOptions): UserRole { + return options.profile?.role ?? DEFAULT_PRODUCT_ROLE; } function getUserName(options: UseAppShellOptions): string { - return options.isAuthenticated && options.profile?.full_name ? options.profile.full_name : GUEST_PREVIEW_USER_NAME; + return options.profile?.full_name ?? ''; } function getUserCampus(options: UseAppShellOptions): string { - return options.isAuthenticated && options.profile?.campus ? options.profile.campus : DEFAULT_CAMPUS_LABEL; + return options.profile?.campus ?? DEFAULT_CAMPUS_LABEL; } export function useAppShell(options: UseAppShellOptions): AppShellState { @@ -49,11 +45,8 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [zoneCheckIn, setZoneCheckIn] = useState(null); - const [showSignInModal, setShowSignInModal] = useState(false); - const [guestPreviewRole, setGuestPreviewRole] = useState(DEFAULT_GUEST_PREVIEW_ROLE); - const [showGuestRolePicker, setShowGuestRolePicker] = useState(false); - const userRole = getUserRole(options, guestPreviewRole); + const userRole = getUserRole(options); const userName = getUserName(options); const userCampus = getUserCampus(options); const currentRouteModule = getModuleIdByRoutePath(location.pathname); @@ -70,11 +63,6 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { } }; - const currentGuestPreviewRole = useMemo( - () => GUEST_PREVIEW_ROLE_OPTIONS.find((role) => role.value === guestPreviewRole) || GUEST_PREVIEW_ROLE_OPTIONS[0], - [guestPreviewRole], - ); - const toggleSidebar = () => { if (options.isMobile) { setMobileSidebarOpen((current) => !current); @@ -109,29 +97,12 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { setCurrentModule, }; - const guestBannerProps = { - guestPreviewRole, - guestPreviewRoles: GUEST_PREVIEW_ROLE_OPTIONS, - currentGuestPreviewRole, - showGuestRolePicker, - setGuestPreviewRole, - setShowGuestRolePicker, - onSignInClick: () => setShowSignInModal(true), - }; - const footerProps = { - isAuthenticated: options.isAuthenticated, userName, userRole, - currentGuestPreviewRole, setCurrentModule, }; - const signInModalProps = { - isOpen: showSignInModal, - onClose: () => setShowSignInModal(false), - }; - return { activeModule, currentModule: activeModule, @@ -143,23 +114,13 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { mobileSidebarOpen, mobileOverlayVisible, zoneCheckIn, - showSignInModal, - guestPreviewRole, - showGuestRolePicker, - guestPreviewRoles: GUEST_PREVIEW_ROLE_OPTIONS, - currentGuestPreviewRole, sidebarProps, topBarProps, shellOutletContext, - guestBannerProps, footerProps, - signInModalProps, setSidebarCollapsed, setMobileSidebarOpen, setZoneCheckIn, - setShowSignInModal, - setGuestPreviewRole, - setShowGuestRolePicker, setCurrentModule, }; } diff --git a/frontend/src/business/app-shell/selectors.test.ts b/frontend/src/business/app-shell/selectors.test.ts index 376ad8b..a0d2b68 100644 --- a/frontend/src/business/app-shell/selectors.test.ts +++ b/frontend/src/business/app-shell/selectors.test.ts @@ -14,7 +14,7 @@ const modules: readonly Module[] = [ id: 'dashboard', name: 'Dashboard', icon: 'home', - roles: ['teacher', 'para', 'office', 'director', 'superintendent'], + roles: ['teacher', 'support_staff', 'office_manager', 'director', 'superintendent'], color: 'text-blue-400', routePath: '/dashboard', }, @@ -46,8 +46,8 @@ describe('app-shell selectors', () => { }); it('formats sidebar role labels through shared auth role labels', () => { - expect(getSidebarRoleLabel('para')).toBe('Support Staff'); - expect(getSidebarRoleLabel('office')).toBe('Office Manager'); + expect(getSidebarRoleLabel('support_staff')).toBe('Support Staff'); + expect(getSidebarRoleLabel('office_manager')).toBe('Office Manager'); }); it('returns a campus initial when campus branding is available', () => { diff --git a/frontend/src/business/app-shell/types.ts b/frontend/src/business/app-shell/types.ts index 84e9d54..a0827b9 100644 --- a/frontend/src/business/app-shell/types.ts +++ b/frontend/src/business/app-shell/types.ts @@ -8,14 +8,7 @@ import type { } from '@/shared/types/app'; import type { TopBarProps } from '@/business/top-bar/types'; -export interface GuestPreviewRoleOption { - readonly value: UserRole; - readonly label: string; - readonly color: string; -} - export interface UseAppShellOptions { - readonly isAuthenticated: boolean; readonly profile: StaffProfile | null; readonly isMobile: boolean; } @@ -31,23 +24,13 @@ export interface AppShellState { readonly mobileSidebarOpen: boolean; readonly mobileOverlayVisible: boolean; readonly zoneCheckIn: string | null; - readonly showSignInModal: boolean; - readonly guestPreviewRole: UserRole; - readonly showGuestRolePicker: boolean; - readonly guestPreviewRoles: readonly GuestPreviewRoleOption[]; - readonly currentGuestPreviewRole: GuestPreviewRoleOption; readonly sidebarProps: SidebarProps; readonly topBarProps: TopBarProps; readonly shellOutletContext: ShellOutletContext; - readonly guestBannerProps: GuestBannerProps; readonly footerProps: AppFooterProps; - readonly signInModalProps: SignInModalProps; readonly setSidebarCollapsed: Dispatch>; readonly setMobileSidebarOpen: Dispatch>; readonly setZoneCheckIn: Dispatch>; - readonly setShowSignInModal: Dispatch>; - readonly setGuestPreviewRole: Dispatch>; - readonly setShowGuestRolePicker: Dispatch>; readonly setCurrentModule: (id: ModuleId) => void; } @@ -81,25 +64,8 @@ export interface SidebarPage { readonly toggleCollapsed: () => void; } -export interface GuestBannerProps { - readonly guestPreviewRole: UserRole; - readonly guestPreviewRoles: readonly GuestPreviewRoleOption[]; - readonly currentGuestPreviewRole: GuestPreviewRoleOption; - readonly showGuestRolePicker: boolean; - readonly setGuestPreviewRole: (role: UserRole) => void; - readonly setShowGuestRolePicker: (open: boolean) => void; - readonly onSignInClick: () => void; -} - export interface AppFooterProps { - readonly isAuthenticated: boolean; readonly userName: string; readonly userRole: UserRole; - readonly currentGuestPreviewRole: GuestPreviewRoleOption; readonly setCurrentModule: (id: ModuleId) => void; } - -export interface SignInModalProps { - readonly isOpen: boolean; - readonly onClose: () => void; -} diff --git a/frontend/src/business/audio-files/generate.test.ts b/frontend/src/business/audio-files/generate.test.ts new file mode 100644 index 0000000..a6a1c03 --- /dev/null +++ b/frontend/src/business/audio-files/generate.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + generateSoundRecipe, + generateSoundTitle, +} from '@/business/audio-files/generate'; + +describe('generateSoundRecipe (local stub)', () => { + it('produces a playable single-voice recipe with 3-5 valid notes', () => { + // Random output — exercise it many times to cover the range. + for (let run = 0; run < 30; run += 1) { + const recipe = generateSoundRecipe(); + expect(recipe.voices).toHaveLength(1); + + const voice = recipe.voices[0]; + expect(['sine', 'triangle']).toContain(voice.waveform); + expect(voice.notes.length).toBeGreaterThanOrEqual(3); + expect(voice.notes.length).toBeLessThanOrEqual(5); + + for (const note of voice.notes) { + expect(note.freq).toBeGreaterThan(0); + expect(note.duration).toBeGreaterThan(0); + expect(note.startAt).toBeGreaterThanOrEqual(0); + expect(note.gain).toBeGreaterThan(0); + expect(note.gain).toBeLessThanOrEqual(1); + } + } + }); + + it('returns a non-empty title', () => { + expect(generateSoundTitle().trim().length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/business/audio-files/generate.ts b/frontend/src/business/audio-files/generate.ts new file mode 100644 index 0000000..e706577 --- /dev/null +++ b/frontend/src/business/audio-files/generate.ts @@ -0,0 +1,52 @@ +import type { SoundRecipe, SoundRecipeWaveform } from '@/shared/types/audioFiles'; + +/** + * Local stub for AI sound generation. Until an AI key is wired, this produces a + * pleasant pseudo-random synthesis recipe entirely client-side — a real, + * audible "generated" sound with no AI call. The rest of the pipeline (persist + * to `audio_files` as a `recipe` row, play via {@link playRecipe}, list in the + * library) is identical to the eventual AI path: only this function's body + * changes when the key lands (local recipe → AI-produced recipe from a prompt). + */ + +// Pentatonic scale — any subset sounds consonant, which keeps random output +// gentle (the timer is used with sound-sensitive students). +const PENTATONIC_SCALE = [523.25, 587.33, 659.25, 783.99, 880, 1046.5]; +const WAVEFORMS: readonly SoundRecipeWaveform[] = ['sine', 'triangle']; +const GENERATED_NAME_PARTS = [ + 'Aurora', + 'Meadow', + 'Tide', + 'Lantern', + 'Willow', + 'Cinder', + 'Pebble', + 'Drift', +]; + +function pick(values: readonly T[]): T { + return values[Math.floor(Math.random() * values.length)]; +} + +export function generateSoundRecipe(): SoundRecipe { + const noteCount = 3 + Math.floor(Math.random() * 3); // 3–5 notes + const waveform = pick(WAVEFORMS); + const ascending = Math.random() < 0.5; + + const notes = Array.from({ length: noteCount }, (_, index) => { + const scaleIndex = ascending ? index : noteCount - 1 - index; + return { + freq: PENTATONIC_SCALE[scaleIndex % PENTATONIC_SCALE.length], + startAt: index * 0.18, + duration: 0.7, + gain: 0.3, + attack: 0.02, + }; + }); + + return { voices: [{ waveform, notes }] }; +} + +export function generateSoundTitle(): string { + return `${pick(GENERATED_NAME_PARTS)} ${pick(GENERATED_NAME_PARTS)}`; +} diff --git a/frontend/src/business/audio-files/hooks.ts b/frontend/src/business/audio-files/hooks.ts new file mode 100644 index 0000000..94f186b --- /dev/null +++ b/frontend/src/business/audio-files/hooks.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +import { + createAudioFile, + deleteAudioFile, + listAudioFiles, +} from '@/shared/api/audioFiles'; +import { getApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import { + generateSoundRecipe, + generateSoundTitle, +} from '@/business/audio-files/generate'; + +export const AUDIO_FILES_QUERY_KEY = ['audio-files'] as const; + +/** + * The audio library visible to the caller: global defaults + own campus rows + * (`READ_AUDIO_FILES`). `retry: false` so a caller without the permission (e.g. + * a non-campus role opening the timer) silently falls back to the built-ins + * instead of retrying a 403. + */ +export function useAudioFiles() { + return useQuery({ + queryKey: AUDIO_FILES_QUERY_KEY, + queryFn: () => getApiListRows(listAudioFiles()), + retry: false, + }); +} + +/** + * Generate a new synthesized sound and persist it as a `recipe` row. An optional + * user-supplied `name` becomes the title; a generated name is the fallback. + */ +export function useGenerateAudioFile() { + return useInvalidatingMutation({ + mutationFn: (name?: string) => + createAudioFile({ + kind: 'recipe', + title: name && name.trim() ? name.trim() : generateSoundTitle(), + recipe: generateSoundRecipe(), + }), + invalidateQueryKey: AUDIO_FILES_QUERY_KEY, + }); +} + +export function useDeleteAudioFile() { + return useInvalidatingMutation({ + mutationFn: (id: string) => deleteAudioFile(id), + invalidateQueryKey: AUDIO_FILES_QUERY_KEY, + }); +} diff --git a/frontend/src/business/audio-files/selectors.test.ts b/frontend/src/business/audio-files/selectors.test.ts new file mode 100644 index 0000000..0561c96 --- /dev/null +++ b/frontend/src/business/audio-files/selectors.test.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..573f7eb --- /dev/null +++ b/frontend/src/business/audio-files/selectors.ts @@ -0,0 +1,15 @@ +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.test.tsx b/frontend/src/business/auth/hooks.test.tsx new file mode 100644 index 0000000..64fd272 --- /dev/null +++ b/frontend/src/business/auth/hooks.test.tsx @@ -0,0 +1,467 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { useAuthSession, useAuthModalWorkflow } from './hooks'; +import * as authApi from '@/shared/api/auth'; +import type { CurrentUser } from '@/shared/types/auth'; +import { AuthExpiredError } from '@/shared/api/httpClient'; + +vi.mock('@/shared/api/auth', () => ({ + getCurrentUser: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock('@/business/campuses/hooks', () => ({ + useCampusCatalog: () => ({ + campuses: [], + isLoading: false, + error: null, + }), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe('useAuthSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initialization', () => { + it('starts with loading=true', async () => { + vi.mocked(authApi.getCurrentUser).mockImplementation( + () => new Promise(() => {}), // Never resolves + ); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + expect(result.current.loading).toBe(true); + }); + + it('fetches current user on mount', async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + }); + + renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(authApi.getCurrentUser).toHaveBeenCalledTimes(1); + }); + }); + + it('sets user when fetch succeeds', async () => { + const mockUser = { + id: 'user-1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + }; + vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + }); + + it('sets loading=false after fetch completes', async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + }); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('handles fetch error gracefully and clears user', async () => { + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.user).toBeNull(); + }); + }); + + it('redirects to login on AuthExpiredError', async () => { + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new AuthExpiredError()); + + renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + }); + }); + + describe('signIn', () => { + it('calls signIn API with credentials', async () => { + const mockUser = { id: 'user-1', email: 'test@example.com' } as CurrentUser; + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); + vi.mocked(authApi.signIn).mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.signIn('test@example.com', 'password123'); + }); + + expect(authApi.signIn).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }); + }); + + it('sets user on successful sign in', async () => { + const mockUser = { id: 'user-1', email: 'test@example.com' } as CurrentUser; + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); + vi.mocked(authApi.signIn).mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.signIn('test@example.com', 'password123'); + }); + + expect(result.current.user).toEqual(mockUser); + }); + + it('returns error on sign in failure', async () => { + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); + vi.mocked(authApi.signIn).mockRejectedValue(new Error('Invalid credentials')); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let signInResult: { error: string | null } | undefined; + await act(async () => { + signInResult = await result.current.signIn('test@example.com', 'wrong'); + }); + + expect(signInResult?.error).toBeTruthy(); + }); + + it('sets loading during sign in request', async () => { + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); + vi.mocked(authApi.signIn).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ id: '1', email: 'test@example.com' } as CurrentUser), 100)), + ); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let signInPromise: Promise<{ error: string | null }>; + act(() => { + signInPromise = result.current.signIn('test@example.com', 'password'); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + await signInPromise; + }); + + expect(result.current.loading).toBe(false); + }); + }); + + describe('signOut', () => { + it('calls signOut API', async () => { + const mockUser = { id: 'user-1', email: 'test@example.com' }; + vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); + vi.mocked(authApi.signOut).mockResolvedValue(undefined); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + await act(async () => { + await result.current.signOut(); + }); + + expect(authApi.signOut).toHaveBeenCalledTimes(1); + }); + + it('clears user on successful sign out', async () => { + const mockUser = { id: 'user-1', email: 'test@example.com' }; + vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); + vi.mocked(authApi.signOut).mockResolvedValue(undefined); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + await act(async () => { + await result.current.signOut(); + }); + + expect(result.current.user).toBeNull(); + }); + + it('handles AuthExpiredError gracefully', async () => { + const mockUser = { id: 'user-1', email: 'test@example.com' }; + vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser); + vi.mocked(authApi.signOut).mockRejectedValue(new AuthExpiredError()); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + mockNavigate.mockClear(); + + let signOutResult: { error: string | null } | undefined; + await act(async () => { + signOutResult = await result.current.signOut(); + }); + + expect(signOutResult?.error).toBeNull(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + }); + + describe('isAuthenticated', () => { + it('returns true when user is set', async () => { + vi.mocked(authApi.getCurrentUser).mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + }); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + }); + + it('returns false when user is null', async () => { + vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Not authenticated')); + + const { result } = renderHook(() => useAuthSession(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.isAuthenticated).toBe(false); + }); + }); +}); + +describe('useAuthModalWorkflow', () => { + const mockSignIn = vi.fn().mockResolvedValue({ error: null }); + const mockSignUp = vi.fn().mockResolvedValue({ error: null }); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with signin mode', () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + expect(result.current.state.mode).toBe('signin'); + }); + + it('initializes with empty draft', () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + expect(result.current.state.draft.email).toBe(''); + expect(result.current.state.draft.password).toBe(''); + expect(result.current.state.draft.fullName).toBe(''); + }); + + it('updates draft when updateDraft is called', () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.updateDraft({ email: 'new@example.com' }); + }); + + expect(result.current.state.draft.email).toBe('new@example.com'); + }); + + it('switches mode when switchMode is called', () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.switchMode('signup'); + }); + + expect(result.current.state.mode).toBe('signup'); + }); + + it('calls signIn with email and password on handleSignIn', async () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.updateDraft({ email: 'test@example.com', password: 'secret' }); + }); + + await act(async () => { + await result.current.actions.handleSignIn(); + }); + + expect(mockSignIn).toHaveBeenCalledWith('test@example.com', 'secret'); + }); + + it('sets error when signIn fails', async () => { + const failingSignIn = vi.fn().mockResolvedValue({ error: 'Invalid credentials' }); + + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: failingSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.updateDraft({ email: 'test@example.com', password: 'wrong' }); + }); + + await act(async () => { + await result.current.actions.handleSignIn(); + }); + + expect(result.current.state.error).toBe('Invalid credentials'); + }); + + it('sets loading during signIn', async () => { + const slowSignIn = vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ error: null }), 100)), + ); + + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: slowSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.updateDraft({ email: 'test@example.com', password: 'secret' }); + }); + + let signInPromise: Promise; + act(() => { + signInPromise = result.current.actions.handleSignIn(); + }); + + expect(result.current.state.loading).toBe(true); + + await act(async () => { + await signInPromise; + }); + + expect(result.current.state.loading).toBe(false); + }); + + it('resets form on handleClose', () => { + const { result } = renderHook( + () => useAuthModalWorkflow({ + signIn: mockSignIn, + signUp: mockSignUp, + onClose: mockOnClose, + }), + { wrapper }, + ); + + act(() => { + result.current.actions.updateDraft({ email: 'test@example.com' }); + result.current.actions.switchMode('signup'); + }); + + expect(result.current.state.draft.email).toBe('test@example.com'); + expect(result.current.state.mode).toBe('signup'); + + act(() => { + result.current.actions.handleClose(); + }); + + expect(result.current.state.draft.email).toBe(''); + expect(result.current.state.mode).toBe('signin'); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/business/auth/mappers.test.ts b/frontend/src/business/auth/mappers.test.ts index bdb153f..a860e52 100644 --- a/frontend/src/business/auth/mappers.test.ts +++ b/frontend/src/business/auth/mappers.test.ts @@ -12,12 +12,11 @@ function createUser(overrides: Partial = {}): CurrentUser { email: 'teacher@example.com', firstName: 'Ava', lastName: 'Lee', - app_role: null, + app_role: { name: 'teacher' }, organizations: null, organizationId: 'org-1', campus: { id: 'campus-1', name: 'North Campus', code: 'north' }, campusId: 'campus-1', - productRole: 'teacher', staffProfile: { id: 'staff-1', employee_number: 'E-1', @@ -53,12 +52,12 @@ describe('auth mappers', () => { expect(toStaffProfile(createUser({ campus: null, campusId: null, - productRole: 'para', + app_role: { name: 'support_staff' }, staffProfile: null, }))).toEqual({ id: 'user-1', full_name: 'Ava Lee', - role: 'para', + role: 'support_staff', campus: DEFAULT_CAMPUS_LABEL, avatar_url: null, }); diff --git a/frontend/src/business/auth/mappers.ts b/frontend/src/business/auth/mappers.ts index e3f3d63..32c51ca 100644 --- a/frontend/src/business/auth/mappers.ts +++ b/frontend/src/business/auth/mappers.ts @@ -1,10 +1,11 @@ -import { DEFAULT_PRODUCT_ROLE } from '@/shared/constants/roles'; +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'; function getProductRole(user: CurrentUser): UserRole { - return user.productRole || DEFAULT_PRODUCT_ROLE; + const name = user.app_role?.name; + return isUserRole(name) ? name : DEFAULT_PRODUCT_ROLE; } export function getUserDisplayName(user: CurrentUser): string { diff --git a/frontend/src/business/auth/permissions.test.ts b/frontend/src/business/auth/permissions.test.ts new file mode 100644 index 0000000..7d5e164 --- /dev/null +++ b/frontend/src/business/auth/permissions.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + hasPermission, + hasAnyPermission, + hasAllPermissions, +} from '@/business/auth/permissions'; +import type { CurrentUser } from '@/shared/types/auth'; + +function user(overrides: Partial = {}): CurrentUser { + return { + id: 'u1', + email: 'staff@example.com', + permissions: [], + ...overrides, + }; +} + +describe('permission selectors', () => { + it('grants a permission the user holds and denies one they do not', () => { + const u = user({ permissions: ['READ_CAMPUSES', 'READ_DASHBOARD'] }); + expect(hasPermission(u, 'READ_CAMPUSES')).toBe(true); + expect(hasPermission(u, 'DELETE_CAMPUSES')).toBe(false); + }); + + it('treats a global-access role as having every permission', () => { + const admin = user({ + permissions: [], + app_role: { name: 'super_admin', globalAccess: true }, + }); + expect(hasPermission(admin, 'DELETE_ORGANIZATIONS')).toBe(true); + expect(hasAllPermissions(admin, ['CREATE_USERS', 'DELETE_USERS'])).toBe(true); + }); + + it('denies everything for a null user', () => { + expect(hasPermission(null, 'READ_CAMPUSES')).toBe(false); + expect(hasAnyPermission(null, ['READ_CAMPUSES'])).toBe(false); + }); + + it('hasAnyPermission needs at least one; hasAllPermissions needs all', () => { + const u = user({ permissions: ['READ_FRAME'] }); + expect(hasAnyPermission(u, ['READ_FRAME', 'READ_DIRECTOR_DASHBOARD'])).toBe(true); + expect(hasAllPermissions(u, ['READ_FRAME', 'READ_DIRECTOR_DASHBOARD'])).toBe(false); + expect(hasAllPermissions(u, ['READ_FRAME'])).toBe(true); + }); + + it('does not treat a non-global role with empty permissions as all-access', () => { + const teacher = user({ + permissions: [], + app_role: { name: 'teacher', globalAccess: false }, + }); + expect(hasPermission(teacher, 'READ_FRAME')).toBe(false); + }); +}); diff --git a/frontend/src/business/auth/permissions.ts b/frontend/src/business/auth/permissions.ts new file mode 100644 index 0000000..a5d4c12 --- /dev/null +++ b/frontend/src/business/auth/permissions.ts @@ -0,0 +1,35 @@ +import type { CurrentUser } from '@/shared/types/auth'; +import 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; +} + +export function hasPermission( + user: CurrentUser | null | undefined, + permission: PermissionName, +): boolean { + return hasGlobalAccess(user) || 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)); +} + +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)); +} diff --git a/frontend/src/business/auth/selectors.test.ts b/frontend/src/business/auth/selectors.test.ts index 44a532f..ecde192 100644 --- a/frontend/src/business/auth/selectors.test.ts +++ b/frontend/src/business/auth/selectors.test.ts @@ -45,7 +45,7 @@ describe('auth selectors', () => { it('maps campus and role display labels', () => { expect(getSignupCampusName('tigers', campuses)).toBe('Tigers'); expect(getSignupCampusName('', campuses)).toBeNull(); - expect(getAuthRoleLabel('para')).toBe('Support Staff'); - expect(getAuthRoleLabel('office')).toBe('Office Manager'); + expect(getAuthRoleLabel('support_staff')).toBe('Support Staff'); + expect(getAuthRoleLabel('office_manager')).toBe('Office Manager'); }); }); diff --git a/frontend/src/business/auth/selectors.ts b/frontend/src/business/auth/selectors.ts index be833cc..cf0bcdb 100644 --- a/frontend/src/business/auth/selectors.ts +++ b/frontend/src/business/auth/selectors.ts @@ -57,15 +57,27 @@ export function getSignupCampusName(campus: CampusId | '', campuses: readonly Ca export function getAuthRoleLabel(role: UserRole): string { switch (role) { - case 'teacher': - return 'Teacher'; - case 'para': - return 'Support Staff'; - case 'office': - return 'Office Manager'; - case 'director': - return 'Director'; + case 'super_admin': + return 'Super Admin'; + case 'system_admin': + return 'System Admin'; + case 'owner': + return 'Owner'; case 'superintendent': return 'Superintendent'; + case 'director': + return 'Director'; + case 'office_manager': + return 'Office Manager'; + case 'teacher': + return 'Teacher'; + case 'support_staff': + return 'Support Staff'; + case 'student': + return 'Student'; + case 'guardian': + return 'Guardian'; + case 'guest': + return 'Guest'; } } diff --git a/frontend/src/business/campus-attendance/hooks.ts b/frontend/src/business/campus-attendance/hooks.ts index 719f24e..f3e7bcf 100644 --- a/frontend/src/business/campus-attendance/hooks.ts +++ b/frontend/src/business/campus-attendance/hooks.ts @@ -110,10 +110,10 @@ export function useCampusAttendancePage({ const roleAccess = { isSuperintendent: userRole === 'superintendent', isDirector: userRole === 'director', - isOfficeManager: userRole === 'office', + isOfficeManager: userRole === 'office_manager', canSeeAllCampuses: userRole === 'superintendent', - canEnterData: userRole === 'office', - canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office', + canEnterData: userRole === 'office_manager', + canPrint: userRole === 'superintendent' || userRole === 'director' || userRole === 'office_manager', }; const campusCatalog = useCampusCatalog(); const campusInfo = findCampusByNameOrCode(campusCatalog.campuses, userCampus); diff --git a/frontend/src/business/classroom-timer/audio-recipe.ts b/frontend/src/business/classroom-timer/audio-recipe.ts new file mode 100644 index 0000000..ae543bc --- /dev/null +++ b/frontend/src/business/classroom-timer/audio-recipe.ts @@ -0,0 +1,37 @@ +import type { SoundRecipe } from '@/shared/types/audioFiles'; + +/** + * Data-driven Web Audio player for a generated sound `recipe`. Mirrors the + * hardcoded `playBuiltInSound` synthesis, but reads its parameters from the + * recipe instead of a switch — so a generated recipe plays with no audio file + * and no network. Each note schedules an oscillator with an attack→exponential + * decay envelope and an optional frequency glissando. + */ +export function playRecipe(recipe: SoundRecipe, audioCtx: AudioContext): void { + const now = audioCtx.currentTime; + + for (const voice of recipe.voices) { + for (const note of voice.notes) { + const startAt = now + Math.max(0, note.startAt); + const endAt = startAt + Math.max(0.05, note.duration); + const attack = Math.min(note.attack ?? 0.02, note.duration); + const peak = Math.max(0.0001, Math.min(note.gain, 1)); + + const oscillator = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + oscillator.type = voice.waveform; + oscillator.frequency.setValueAtTime(note.freq, startAt); + if (note.rampFreqTo !== undefined) { + oscillator.frequency.linearRampToValueAtTime(note.rampFreqTo, endAt); + } + + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.linearRampToValueAtTime(peak, startAt + attack); + gain.gain.exponentialRampToValueAtTime(0.0001, endAt); + + oscillator.connect(gain).connect(audioCtx.destination); + oscillator.start(startAt); + oscillator.stop(endAt); + } + } +} diff --git a/frontend/src/business/classroom-timer/hooks.ts b/frontend/src/business/classroom-timer/hooks.ts index fac3091..288f325 100644 --- a/frontend/src/business/classroom-timer/hooks.ts +++ b/frontend/src/business/classroom-timer/hooks.ts @@ -2,6 +2,13 @@ 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 { + useAudioFiles, + useDeleteAudioFile, + useGenerateAudioFile, +} from '@/business/audio-files/hooks'; +import { canManageAudioFiles } from '@/business/audio-files/selectors'; import { getFirstQueryError, isAnyLoading } from '@/shared/business/queryState'; import { createTimerParticles, @@ -20,14 +27,22 @@ import { TIMER_PREVIEW_DURATION_MS, } from '@/shared/constants/classroomTimer'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +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, - TimerSoundType, } from '@/shared/types/classroomTimer'; +import type { AudioFileDto } from '@/shared/types/audioFiles'; +import type { UserRole } from '@/shared/types/app'; declare global { interface Window { @@ -35,7 +50,7 @@ declare global { } } -export function useClassroomTimer() { +export function useClassroomTimer(userRole: UserRole) { const backgroundsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.classroomTimerBackgrounds, [], @@ -44,6 +59,10 @@ export function useClassroomTimer() { CONTENT_CATALOG_TYPES.classroomTimerSounds, [], ); + const audioFilesQuery = useAudioFiles(); + const generateMutation = useGenerateAudioFile(); + const deleteMutation = useDeleteAudioFile(); + const canManageAudio = canManageAudioFiles(userRole); const presetsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.classroomTimerPresets, [], @@ -58,7 +77,7 @@ export function useClassroomTimer() { const [isFinished, setIsFinished] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [selectedBackgroundId, setSelectedBackgroundId] = useState(null); - const [selectedSoundId, setSelectedSoundId] = useState(null); + const [selectedSoundKey, setSelectedSoundKey] = useState(null); const [soundEnabled, setSoundEnabled] = useState(true); const [customMinutes, setCustomMinutes] = useState(DEFAULT_CUSTOM_MINUTES); const [customSeconds, setCustomSeconds] = useState(DEFAULT_CUSTOM_SECONDS); @@ -77,7 +96,7 @@ export function useClassroomTimer() { [], ); const backgrounds = backgroundsQuery.payload; - const sounds = soundsQuery.payload; + const builtInOptions = soundsQuery.payload; const presets = presetsQuery.payload; const tips = tipsQuery.payload; const contentQueries = [backgroundsQuery, soundsQuery, presetsQuery, tipsQuery]; @@ -85,9 +104,64 @@ export function useClassroomTimer() { () => backgrounds.find((background) => background.id === selectedBackgroundId) ?? backgrounds[0] ?? null, [backgrounds, selectedBackgroundId], ); + + const audioRows = useMemo( + () => audioFilesQuery.data ?? [], + [audioFilesQuery.data], + ); + + // Built-in synthesized sounds (hardcoded) + the audio library, grouped by + // origin for a clear picker structure. + const soundGroups = useMemo(() => { + const builtinSounds: TimerSound[] = builtInOptions.map((option) => ({ + key: `builtin:${option.id}`, + name: option.name, + icon: option.icon, + kind: 'builtin', + soundType: option.id, + recipe: null, + url: null, + audioFileId: null, + canDelete: false, + })); + + const toAudioSound = (row: AudioFileDto): TimerSound => ({ + key: `audio:${row.id}`, + name: row.title, + icon: row.kind === 'recipe' ? TIMER_GENERATED_SOUND_ICON : TIMER_UPLOADED_SOUND_ICON, + kind: row.kind, + soundType: null, + recipe: row.recipe, + url: row.url, + audioFileId: row.id, + // A global default (no organization) is read-only; own-org rows are + // deletable by managers. The backend enforces this regardless. + canDelete: canManageAudio && row.organizationId !== null, + }); + + const generatedSounds = audioRows + .filter((row) => row.kind === 'recipe') + .map(toAudioSound); + const uploadedSounds = audioRows + .filter((row) => row.kind === 'file' || row.kind === 'url') + .map(toAudioSound); + + return [ + { id: 'builtin', label: TIMER_SOUND_GROUP_LABELS.builtin, sounds: builtinSounds }, + { id: 'generated', label: TIMER_SOUND_GROUP_LABELS.generated, sounds: generatedSounds }, + { id: 'uploaded', label: TIMER_SOUND_GROUP_LABELS.uploaded, sounds: uploadedSounds }, + ]; + }, [audioRows, builtInOptions, canManageAudio]); + + const allSounds = useMemo( + () => soundGroups.flatMap((group) => group.sounds), + [soundGroups], + ); + const selectedSound = useMemo( - () => sounds.find((sound) => sound.id === selectedSoundId) ?? sounds[0] ?? null, - [selectedSoundId, sounds], + () => + allSounds.find((sound) => sound.key === selectedSoundKey) ?? allSounds[0] ?? null, + [allSounds, selectedSoundKey], ); const getAudioContext = useCallback((): AudioContext => { @@ -108,6 +182,26 @@ export function useClassroomTimer() { return audioCtxRef.current; }, []); + // Plays any sound regardless of origin: a built-in is synthesized by id, a + // recipe is synthesized from parameters, a file/url is played from its URL. + const playTimerSound = useCallback( + (sound: TimerSound) => { + if (sound.kind === 'builtin' && sound.soundType) { + playBuiltInSound(sound.soundType, getAudioContext()); + return; + } + if (sound.kind === 'recipe' && sound.recipe) { + playRecipe(sound.recipe, getAudioContext()); + return; + } + if (sound.url) { + const audio = new Audio(sound.url); + void audio.play().catch(() => undefined); + } + }, + [getAudioContext], + ); + useEffect(() => { if (isRunning && remainingSeconds > 0) { intervalRef.current = window.setInterval(() => { @@ -135,14 +229,13 @@ export function useClassroomTimer() { return; } - const audioContext = getAudioContext(); - playBuiltInSound(selectedSound.id, audioContext); + playTimerSound(selectedSound); const repeatSoundTimeout = window.setTimeout(() => { - playBuiltInSound(selectedSound.id, audioContext); + playTimerSound(selectedSound); }, TIMER_FINISH_REPEAT_DELAY_MS); return () => window.clearTimeout(repeatSoundTimeout); - }, [getAudioContext, isFinished, selectedSound, soundEnabled]); + }, [playTimerSound, isFinished, selectedSound, soundEnabled]); useEffect(() => { const handleFullscreenChange = () => { @@ -204,16 +297,30 @@ export function useClassroomTimer() { setIsFullscreen(false); }, [isFullscreen]); - const previewSound = useCallback((soundId: TimerSoundType) => { + const previewSound = useCallback((sound: TimerSound) => { if (previewPlaying) { return; } setPreviewPlaying(true); - const audioContext = getAudioContext(); - playBuiltInSound(soundId, audioContext); + playTimerSound(sound); window.setTimeout(() => setPreviewPlaying(false), TIMER_PREVIEW_DURATION_MS); - }, [getAudioContext, previewPlaying]); + }, [playTimerSound, previewPlaying]); + + const generateSound = useCallback(async (name?: string) => { + const created = await generateMutation.mutateAsync(name); + setSelectedSoundKey(`audio:${created.id}`); + }, [generateMutation]); + + const deleteSound = useCallback( + async (audioFileId: string) => { + await deleteMutation.mutateAsync(audioFileId); + setSelectedSoundKey((current) => + current === `audio:${audioFileId}` ? null : current, + ); + }, + [deleteMutation], + ); const progress = getTimerProgress(totalSeconds, remainingSeconds); const formattedTime = formatTimerTime(remainingSeconds); @@ -224,7 +331,7 @@ export function useClassroomTimer() { return { state: { backgrounds, - sounds, + soundGroups, presets, tips, totalSeconds, @@ -238,6 +345,9 @@ export function useClassroomTimer() { customMinutes, customSeconds, previewPlaying, + canManageAudio, + isGeneratingSound: generateMutation.isPending, + isDeletingSound: deleteMutation.isPending, progress, formattedTime, urgencyColor, @@ -257,8 +367,10 @@ export function useClassroomTimer() { handleCustomTime, toggleFullscreen, previewSound, + generateSound, + deleteSound, setSelectedBackground: (background: SensoryBackground) => setSelectedBackgroundId(background.id), - setSelectedSound: (sound: TimerSoundOption) => setSelectedSoundId(sound.id), + setSelectedSound: (sound: TimerSound) => setSelectedSoundKey(sound.key), setSoundEnabled, setCustomMinutes, setCustomSeconds, diff --git a/frontend/src/business/communications/selectors.test.ts b/frontend/src/business/communications/selectors.test.ts index fae7b35..f0a5f4d 100644 --- a/frontend/src/business/communications/selectors.test.ts +++ b/frontend/src/business/communications/selectors.test.ts @@ -31,8 +31,8 @@ describe('communication selectors', () => { expect(canCreateCommunicationEvents('director')).toBe(true); expect(canCreateCommunicationEvents('superintendent')).toBe(true); expect(canCreateCommunicationEvents('teacher')).toBe(false); - expect(canCreateCommunicationEvents('para')).toBe(false); - expect(canCreateCommunicationEvents('office')).toBe(false); + expect(canCreateCommunicationEvents('support_staff')).toBe(false); + expect(canCreateCommunicationEvents('office_manager')).toBe(false); }); it('resolves selected template category and falls back to general', () => { @@ -50,11 +50,11 @@ describe('communication selectors', () => { const events = [ createEvent({ id: 'teacher-event', roles: ['teacher'] }), createEvent({ id: 'director-event', roles: ['director', 'superintendent'] }), - createEvent({ id: 'office-event', roles: ['office'] }), + createEvent({ id: 'office-event', roles: ['office_manager'] }), ]; expect(filterCommunicationEventsByRole(events, 'director')).toEqual([events[1]]); - expect(filterCommunicationEventsByRole(events, 'office')).toEqual([events[2]]); + expect(filterCommunicationEventsByRole(events, 'office_manager')).toEqual([events[2]]); }); it('normalizes unsupported event types to meeting', () => { diff --git a/frontend/src/business/dashboard/selectors.test.ts b/frontend/src/business/dashboard/selectors.test.ts index 971aeb2..7ac3d1b 100644 --- a/frontend/src/business/dashboard/selectors.test.ts +++ b/frontend/src/business/dashboard/selectors.test.ts @@ -73,7 +73,7 @@ describe('dashboard selectors', () => { }); it('hides classroom quick action for office role', () => { - expect(selectDashboardQuickActions('office').some((action) => action.module === 'classroom')).toBe(false); + expect(selectDashboardQuickActions('office_manager').some((action) => action.module === 'classroom')).toBe(false); expect(selectDashboardQuickActions('teacher').some((action) => action.module === 'classroom')).toBe(true); }); }); diff --git a/frontend/src/business/frame/hooks.ts b/frontend/src/business/frame/hooks.ts index 6fdec60..d106cff 100644 --- a/frontend/src/business/frame/hooks.ts +++ b/frontend/src/business/frame/hooks.ts @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { createFrameEntry, + deleteFrameEntry, listFrameEntries, updateFrameEntry, } from '@/shared/api/frame'; @@ -63,7 +64,7 @@ export function useFrameEntries() { export function useFrameModule(userRole: UserRole, userName: string) { const canEdit = canEditFrameEntries(userRole); const authorName = userName.trim(); - const [expandedIdState, setExpandedId] = useState(''); + const [expandedIdState, setExpandedIdState] = useState(null); const [isEditing, setIsEditing] = useState(false); const [editEntry, setEditEntry] = useState(null); const [showNewForm, setShowNewForm] = useState(false); @@ -76,7 +77,7 @@ export function useFrameModule(userRole: UserRole, userName: string) { mutationFn: (entry: EditableFrameEntry) => createFrameEntry(toFrameEntryMutationDto(entry)), invalidateQueryKey: FRAME_QUERY_KEYS.entries, onSuccess: (createdEntry) => { - setExpandedId(createdEntry.id); + setExpandedIdState(createdEntry.id); setShowNewForm(false); setNewEntry(createEmptyDraft(authorName)); setFormError(null); @@ -87,7 +88,18 @@ export function useFrameModule(userRole: UserRole, userName: string) { mutationFn: (entry: FrameEntryViewModel) => updateFrameEntry(entry.id, toFrameEntryMutationDto(entry)), invalidateQueryKey: FRAME_QUERY_KEYS.entries, onSuccess: (updatedEntry) => { - setExpandedId(updatedEntry.id); + setExpandedIdState(updatedEntry.id); + setIsEditing(false); + setEditEntry(null); + setFormError(null); + }, + }); + + const deleteMutation = useInvalidatingMutation({ + mutationFn: (id: string) => deleteFrameEntry(id), + invalidateQueryKey: FRAME_QUERY_KEYS.entries, + onSuccess: () => { + setExpandedIdState(null); setIsEditing(false); setEditEntry(null); setFormError(null); @@ -95,7 +107,7 @@ export function useFrameModule(userRole: UserRole, userName: string) { }); const entries = entriesQuery.data ?? EMPTY_FRAME_ENTRIES; - const expandedId = expandedIdState || entries[0]?.id || ''; + const expandedId = expandedIdState === null ? (entries[0]?.id || '') : expandedIdState; function updateNewEntryField(key: keyof FrameEntryDraft, value: string) { setNewEntry((current) => ({ ...current, [key]: value })); @@ -143,6 +155,10 @@ export function useFrameModule(userRole: UserRole, userName: string) { await updateMutation.mutateAsync(editEntry); } + async function deleteEntry(id: string) { + await deleteMutation.mutateAsync(id); + } + return { entries, expandedId, @@ -155,8 +171,9 @@ export function useFrameModule(userRole: UserRole, userName: string) { isLoading: entriesQuery.isLoading, isRefreshing: entriesQuery.isFetching, isSaving: createMutation.isPending || updateMutation.isPending, - error: entriesQuery.error || createMutation.error || updateMutation.error, - setExpandedId, + isDeleting: deleteMutation.isPending, + error: entriesQuery.error || createMutation.error || updateMutation.error || deleteMutation.error, + setExpandedId: setExpandedIdState, setShowNewForm, updateNewEntryField, updateNewEntrySection, @@ -165,6 +182,7 @@ export function useFrameModule(userRole: UserRole, userName: string) { cancelEditing, saveNewEntry, saveEditEntry, + deleteEntry, refresh: entriesQuery.refetch, }; } diff --git a/frontend/src/business/frame/mappers.test.ts b/frontend/src/business/frame/mappers.test.ts index 34627aa..e91c31e 100644 --- a/frontend/src/business/frame/mappers.test.ts +++ b/frontend/src/business/frame/mappers.test.ts @@ -26,8 +26,8 @@ describe('frame mappers', () => { expect(toFrameEntryViewModel(dto)).toEqual({ id: 'frame-1', - weekOf: '2026-06-08', - postedDate: '2026-06-08', + weekOf: 'June 8, 2026', + postedDate: 'June 8, 2026', formal: 'Formal note', recognition: 'Recognition note', application: 'Application note', diff --git a/frontend/src/business/frame/mappers.ts b/frontend/src/business/frame/mappers.ts index 0e460c5..0959e1e 100644 --- a/frontend/src/business/frame/mappers.ts +++ b/frontend/src/business/frame/mappers.ts @@ -1,11 +1,25 @@ import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame'; import { EditableFrameEntry, FrameEntryViewModel } from '@/business/frame/types'; +function formatDisplayDate(isoDate: string): string { + const date = new Date(isoDate); + + if (Number.isNaN(date.getTime())) { + return isoDate; + } + + return date.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel { return { id: dto.id, - weekOf: dto.week_of, - postedDate: dto.posted_date, + weekOf: formatDisplayDate(dto.week_of), + postedDate: formatDisplayDate(dto.posted_date), formal: dto.formal, recognition: dto.recognition, application: dto.application, diff --git a/frontend/src/business/frame/selectors.test.ts b/frontend/src/business/frame/selectors.test.ts index 98f404f..fccb018 100644 --- a/frontend/src/business/frame/selectors.test.ts +++ b/frontend/src/business/frame/selectors.test.ts @@ -10,7 +10,7 @@ describe('frame selectors', () => { }); it('keeps staff roles read-only for FRAME entries', () => { - const readOnlyRoles: readonly UserRole[] = ['teacher', 'para', 'office']; + 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/policies/hooks.ts b/frontend/src/business/policies/hooks.ts index 0f76251..236eba9 100644 --- a/frontend/src/business/policies/hooks.ts +++ b/frontend/src/business/policies/hooks.ts @@ -1,14 +1,21 @@ import { useQuery } from '@tanstack/react-query'; import { - createDocument, - deleteDocument, + createPolicyDocument, + deletePolicyDocument, listPolicyDocuments, - updateDocument, -} from '@/shared/api/documents'; -import { POLICY_QUERY_KEYS } from '@/shared/constants/policies'; + updatePolicyDocument, +} from '@/shared/api/policyDocuments'; +import { + acknowledgePolicyDocument, + listMyPolicyAcknowledgments, +} from '@/shared/api/policyAcknowledgments'; +import { + POLICY_DOCUMENT_PAGE_CATEGORY, + POLICY_QUERY_KEYS, +} from '@/shared/constants/policies'; import { toPolicyDocumentMutationDto, toPolicyViewModel } from '@/business/policies/mappers'; import type { PolicyFormInput } from '@/business/policies/types'; -import { mapApiListRows } from '@/shared/business/apiListRows'; +import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; interface PolicyUpdateInput { @@ -19,30 +26,49 @@ interface PolicyUpdateInput { export function usePolicies() { return useQuery({ queryKey: POLICY_QUERY_KEYS.documents, - queryFn: () => mapApiListRows(listPolicyDocuments(), toPolicyViewModel), + queryFn: () => + mapApiListRows( + listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies), + toPolicyViewModel, + ), }); } export function useCreatePolicy() { return useInvalidatingMutation({ - mutationFn: (input: PolicyFormInput) => createDocument(toPolicyDocumentMutationDto(input)), + mutationFn: (input: PolicyFormInput) => + createPolicyDocument(toPolicyDocumentMutationDto(input)), invalidateQueryKey: POLICY_QUERY_KEYS.documents, }); } export function useUpdatePolicy() { return useInvalidatingMutation({ - mutationFn: (input: PolicyUpdateInput) => updateDocument( - input.id, - toPolicyDocumentMutationDto(input.policy), - ), + mutationFn: (input: PolicyUpdateInput) => + updatePolicyDocument(input.id, toPolicyDocumentMutationDto(input.policy)), invalidateQueryKey: POLICY_QUERY_KEYS.documents, }); } export function useDeletePolicy() { return useInvalidatingMutation({ - mutationFn: (id: string) => deleteDocument(id), + mutationFn: (id: string) => deletePolicyDocument(id), invalidateQueryKey: POLICY_QUERY_KEYS.documents, }); } + +/** The current user's persisted policy acknowledgments (replaces local state). */ +export function usePolicyAcknowledgments() { + return useQuery({ + queryKey: POLICY_QUERY_KEYS.acknowledgments, + queryFn: () => getApiListRows(listMyPolicyAcknowledgments()), + }); +} + +export function useAcknowledgePolicy() { + return useInvalidatingMutation({ + mutationFn: (policyDocumentId: string) => + acknowledgePolicyDocument({ policyDocumentId }), + invalidateQueryKey: POLICY_QUERY_KEYS.acknowledgments, + }); +} diff --git a/frontend/src/business/policies/mappers.test.ts b/frontend/src/business/policies/mappers.test.ts index 7a73683..6e8fe14 100644 --- a/frontend/src/business/policies/mappers.test.ts +++ b/frontend/src/business/policies/mappers.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { toPolicyDocumentMutationDto, toPolicyViewModel, @@ -7,63 +7,50 @@ import type { PolicyFormInput } from '@/business/policies/types'; import { POLICY_DATE_NOT_RECORDED_LABEL, POLICY_DEFAULT_CATEGORY, - POLICY_DOCUMENT_CATEGORY, - POLICY_DOCUMENT_ENTITY_TYPE, + POLICY_DOCUMENT_PAGE_CATEGORY, POLICY_UPDATED_BY_LABEL, } from '@/shared/constants/policies'; -import type { DocumentDto } from '@/shared/types/documents'; +import type { PolicyDocumentDto } from '@/shared/types/policyDocuments'; + +function dto(overrides: Partial = {}): PolicyDocumentDto { + return { + id: 'policy-1', + title: 'Incident Response', + body: 'Use the approved incident response process.', + category: 'handbook_policy', + tag: 'Safety', + author: 'Dr. Williams', + steps: null, + autism_considerations: null, + version: 2, + active: true, + organizationId: 'org-1', + campusId: null, + createdAt: '2026-06-07T08:00:00.000Z', + updatedAt: '2026-06-08T09:30:00.000Z', + ...overrides, + }; +} describe('policy mappers', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('maps backend document DTO fields into the frontend policy view model shape', () => { - const dto: DocumentDto = { - id: 'policy-1', - entity_type: 'organization', - entity_reference: 'Safety', - name: 'Incident Response', - category: 'policy', - uploaded_at: '2026-06-07T08:00:00.000Z', - notes: 'Use the approved incident response process.', - organizationId: 'org-1', - campusId: null, - createdById: 'user-1', - updatedById: 'user-2', - createdAt: '2026-06-07T08:00:00.000Z', - updatedAt: '2026-06-08T09:30:00.000Z', - }; - - expect(toPolicyViewModel(dto)).toEqual({ + it('maps a policy_documents row (handbook_policy) into the view model; tag → category', () => { + expect(toPolicyViewModel(dto())).toEqual({ id: 'policy-1', title: 'Incident Response', category: 'Safety', content: 'Use the approved incident response process.', lastUpdated: '2026-06-08', - updatedBy: POLICY_UPDATED_BY_LABEL, + updatedBy: 'Dr. Williams', }); }); - it('defaults missing document fields into explicit policy view model values', () => { - const dto: DocumentDto = { - id: 'policy-2', - entity_type: null, - entity_reference: 'Unknown Category', - name: null, - category: null, - uploaded_at: null, - notes: null, - organizationId: null, - campusId: null, - createdById: null, - updatedById: null, - createdAt: '2026-06-07T08:00:00.000Z', - updatedAt: '', - }; - - expect(toPolicyViewModel(dto)).toEqual({ - id: 'policy-2', + it('defaults missing/invalid fields into explicit view model values', () => { + expect( + toPolicyViewModel( + dto({ title: '', body: null, tag: 'Unknown Tag', author: null, updatedAt: '' }), + ), + ).toEqual({ + id: 'policy-1', title: '', category: POLICY_DEFAULT_CATEGORY, content: '', @@ -72,10 +59,7 @@ describe('policy mappers', () => { }); }); - it('maps policy form input back into the backend document mutation DTO shape', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-08T12:34:56.000Z')); - + it('maps form input back into the policy_documents mutation DTO (category=handbook_policy, tag=sub-category)', () => { const input: PolicyFormInput = { title: ' Communication Standards ', category: 'Communication', @@ -83,12 +67,10 @@ describe('policy mappers', () => { }; expect(toPolicyDocumentMutationDto(input)).toEqual({ - entity_type: POLICY_DOCUMENT_ENTITY_TYPE, - entity_reference: 'Communication', - name: 'Communication Standards', - category: POLICY_DOCUMENT_CATEGORY, - uploaded_at: '2026-06-08T12:34:56.000Z', - notes: 'Use plain language with families.', + title: 'Communication Standards', + category: POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies, + tag: 'Communication', + body: 'Use plain language with families.', }); }); }); diff --git a/frontend/src/business/policies/mappers.ts b/frontend/src/business/policies/mappers.ts index edd657c..dd4df15 100644 --- a/frontend/src/business/policies/mappers.ts +++ b/frontend/src/business/policies/mappers.ts @@ -2,11 +2,13 @@ import { POLICY_CATEGORIES, POLICY_DATE_NOT_RECORDED_LABEL, POLICY_DEFAULT_CATEGORY, - POLICY_DOCUMENT_CATEGORY, - POLICY_DOCUMENT_ENTITY_TYPE, + POLICY_DOCUMENT_PAGE_CATEGORY, POLICY_UPDATED_BY_LABEL, } from '@/shared/constants/policies'; -import type { DocumentDto, DocumentMutationDto } from '@/shared/types/documents'; +import type { + PolicyDocumentDto, + PolicyDocumentMutationDto, +} from '@/shared/types/policyDocuments'; import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types'; function isPolicyCategory(value: string | null): value is PolicyCategory { @@ -25,26 +27,28 @@ function toDateOnly(value: string | null): string { return value.split('T')[0] || POLICY_DATE_NOT_RECORDED_LABEL; } -export function toPolicyViewModel(dto: DocumentDto): PolicyViewModel { +/** + * Maps a unified `policy_documents` row (category `handbook_policy`) into the + * handbook page's view model. The handbook's sub-category lives in `tag`. + */ +export function toPolicyViewModel(dto: PolicyDocumentDto): PolicyViewModel { return { id: dto.id, - title: dto.name || '', - category: toPolicyCategory(dto.entity_reference), - content: dto.notes || '', - lastUpdated: toDateOnly(dto.updatedAt || dto.uploaded_at), - updatedBy: POLICY_UPDATED_BY_LABEL, + title: dto.title || '', + category: toPolicyCategory(dto.tag), + content: dto.body || '', + lastUpdated: toDateOnly(dto.updatedAt), + updatedBy: dto.author || POLICY_UPDATED_BY_LABEL, }; } -export function toPolicyDocumentMutationDto(input: PolicyFormInput): DocumentMutationDto { - const recordedAt = new Date().toISOString(); - +export function toPolicyDocumentMutationDto( + input: PolicyFormInput, +): PolicyDocumentMutationDto { return { - entity_type: POLICY_DOCUMENT_ENTITY_TYPE, - entity_reference: input.category, - name: input.title.trim(), - category: POLICY_DOCUMENT_CATEGORY, - uploaded_at: recordedAt, - notes: input.content.trim(), + title: input.title.trim(), + category: POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies, + tag: input.category, + body: input.content.trim(), }; } diff --git a/frontend/src/business/policies/pageHooks.ts b/frontend/src/business/policies/pageHooks.ts index 562c903..8b5b447 100644 --- a/frontend/src/business/policies/pageHooks.ts +++ b/frontend/src/business/policies/pageHooks.ts @@ -1,9 +1,11 @@ import { useState } from 'react'; import { + useAcknowledgePolicy, useCreatePolicy, useDeletePolicy, usePolicies, + usePolicyAcknowledgments, useUpdatePolicy, } from '@/business/policies/hooks'; import { @@ -75,12 +77,17 @@ export function usePoliciesPage(userRole: UserRole): PoliciesPage { const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [expandedPolicyId, setExpandedPolicyId] = useState(null); - const [acknowledgedPolicyIds, setAcknowledgedPolicyIds] = useState>(new Set()); const [showCreateForm, setShowCreateForm] = useState(false); const [editingPolicyId, setEditingPolicyId] = useState(null); const [createDraft, setCreateDraft] = useState(emptyPolicyDraft); const [editDraft, setEditDraft] = useState(emptyPolicyDraft); + const acknowledgmentsQuery = usePolicyAcknowledgments(); + const acknowledgePolicy = useAcknowledgePolicy(); + const acknowledgedPolicyIds: ReadonlySet = new Set( + (acknowledgmentsQuery.data ?? []).map((ack) => ack.policyDocumentId), + ); + const policies = policiesQuery.data ?? []; const filteredPolicies = filterPolicies(policies, searchQuery, categoryFilter); const isMutating = createPolicy.isPending || updatePolicy.isPending || deletePolicyMutation.isPending; @@ -121,16 +128,12 @@ export function usePoliciesPage(userRole: UserRole): PoliciesPage { setExpandedPolicyId((currentId) => (currentId === id ? null : id)); }; + // Acknowledgment is a persisted, one-way action (you cannot un-acknowledge a + // version); re-acknowledging is idempotent on the backend. const toggleAcknowledgement = (id: string) => { - setAcknowledgedPolicyIds((currentIds) => { - const nextIds = new Set(currentIds); - if (nextIds.has(id)) { - nextIds.delete(id); - } else { - nextIds.add(id); - } - return nextIds; - }); + if (!acknowledgedPolicyIds.has(id)) { + acknowledgePolicy.mutate(id); + } }; return { diff --git a/frontend/src/business/policies/selectors.test.ts b/frontend/src/business/policies/selectors.test.ts index 3081874..2b4d0be 100644 --- a/frontend/src/business/policies/selectors.test.ts +++ b/frontend/src/business/policies/selectors.test.ts @@ -38,12 +38,14 @@ const policies: readonly PolicyViewModel[] = [ ]; describe('policy selectors', () => { - it('allows only director and superintendent roles to manage policies', () => { - expect(canManagePolicies('director')).toBe(true); + 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('para')).toBe(false); - expect(canManagePolicies('office')).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', () => { diff --git a/frontend/src/business/policies/selectors.ts b/frontend/src/business/policies/selectors.ts index 3096f74..3666b72 100644 --- a/frontend/src/business/policies/selectors.ts +++ b/frontend/src/business/policies/selectors.ts @@ -2,8 +2,19 @@ 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 === 'director' || userRole === 'superintendent'; + return ( + userRole === 'owner' || + userRole === 'superintendent' || + userRole === 'director' || + userRole === 'office_manager' + ); } export function getPolicyCategoryFilters(): readonly (PolicyCategory | 'all')[] { diff --git a/frontend/src/business/safety-protocols/hooks.ts b/frontend/src/business/safety-protocols/hooks.ts new file mode 100644 index 0000000..ad35fb9 --- /dev/null +++ b/frontend/src/business/safety-protocols/hooks.ts @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + createPolicyDocument, + deletePolicyDocument, + listPolicyDocuments, + updatePolicyDocument, +} from '@/shared/api/policyDocuments'; +import { POLICY_DOCUMENT_PAGE_CATEGORY, POLICY_QUERY_KEYS } from '@/shared/constants/policies'; +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 { + toSafetyProtocolMutationDto, + toSafetyProtocolViewModel, +} from '@/business/safety-protocols/mappers'; +import { + canManageSafetyProtocols, + isSafetyDraftValid, +} from '@/business/safety-protocols/selectors'; +import type { + SafetyProtocolDraft, + SafetyProtocolListKey, + SafetyProtocolViewModel, +} from '@/business/safety-protocols/types'; +import type { UserRole } from '@/shared/types/app'; + +const EMPTY_SAFETY_PROTOCOLS: readonly SafetyProtocolViewModel[] = []; + +/** Safety protocols = `policy_documents` of category `safety_protocol`. */ +export function useSafetyProtocols() { + return useQuery({ + queryKey: POLICY_QUERY_KEYS.safetyDocuments, + queryFn: () => + mapApiListRows( + listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.safetyProtocols), + toSafetyProtocolViewModel, + ), + }); +} + +// Acknowledgment is shared across all policy documents (handbook + safety): +export { + usePolicyAcknowledgments as useSafetyAcknowledgments, + useAcknowledgePolicy as useAcknowledgeSafetyProtocol, +} from '@/business/policies/hooks'; + +function createEmptySafetyDraft(): SafetyProtocolDraft { + return { + title: '', + tag: SAFETY_PROTOCOL_DEFAULT_TAG, + steps: [''], + autismConsiderations: [''], + }; +} + +interface SafetyUpdateInput { + readonly id: string; + readonly draft: SafetyProtocolDraft; +} + +/** + * Page workflow for the Safety Protocols module — mirrors the F.R.A.M.E. module + * hook: list + persistent acknowledgment plus a manager-gated authoring flow + * (create / inline edit / delete) over the unified `policy_documents` store. The + * `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); + const protocolsQuery = useSafetyProtocols(); + const acknowledgmentsQuery = usePolicyAcknowledgments(); + const acknowledgeProtocol = useAcknowledgePolicy(); + + const [expandedId, setExpandedId] = useState(null); + const [showNewForm, setShowNewForm] = useState(false); + const [editId, setEditId] = useState(null); + const [draft, setDraft] = useState(createEmptySafetyDraft); + const [formError, setFormError] = useState(null); + + function resetForm() { + setShowNewForm(false); + setEditId(null); + setDraft(createEmptySafetyDraft()); + setFormError(null); + } + + const createMutation = useInvalidatingMutation({ + mutationFn: (input: SafetyProtocolDraft) => + createPolicyDocument(toSafetyProtocolMutationDto(input)), + invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + onSuccess: resetForm, + }); + + const updateMutation = useInvalidatingMutation({ + mutationFn: (input: SafetyUpdateInput) => + updatePolicyDocument(input.id, toSafetyProtocolMutationDto(input.draft)), + invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + onSuccess: resetForm, + }); + + const deleteMutation = useInvalidatingMutation({ + mutationFn: (id: string) => deletePolicyDocument(id), + invalidateQueryKey: POLICY_QUERY_KEYS.safetyDocuments, + onSuccess: resetForm, + }); + + const protocols = protocolsQuery.data ?? EMPTY_SAFETY_PROTOCOLS; + const acknowledgedIds: ReadonlySet = new Set( + (acknowledgmentsQuery.data ?? []).map((ack) => ack.policyDocumentId), + ); + + function updateDraftField(patch: Partial) { + setDraft((current) => ({ ...current, ...patch })); + } + + function updateListItem(key: SafetyProtocolListKey, index: number, value: string) { + setDraft((current) => { + const next = [...current[key]]; + next[index] = value; + return { ...current, [key]: next }; + }); + } + + function addListItem(key: SafetyProtocolListKey) { + setDraft((current) => ({ ...current, [key]: [...current[key], ''] })); + } + + function removeListItem(key: SafetyProtocolListKey, index: number) { + setDraft((current) => { + const next = current[key].filter((_, itemIndex) => itemIndex !== index); + // Keep at least one editable row so the field never disappears entirely. + return { ...current, [key]: next.length > 0 ? next : [''] }; + }); + } + + function openNewForm() { + setEditId(null); + setDraft(createEmptySafetyDraft()); + setFormError(null); + setShowNewForm(true); + } + + function startEditing(protocol: SafetyProtocolViewModel) { + setShowNewForm(false); + setEditId(protocol.id); + setFormError(null); + setDraft({ + title: protocol.title, + tag: protocol.tag ?? SAFETY_PROTOCOL_DEFAULT_TAG, + steps: protocol.steps.length > 0 ? [...protocol.steps] : [''], + autismConsiderations: + protocol.autismConsiderations.length > 0 + ? [...protocol.autismConsiderations] + : [''], + }); + } + + async function saveDraft() { + if (!isSafetyDraftValid(draft)) { + setFormError('Add a title and at least one procedure step before saving.'); + return; + } + + if (editId) { + await updateMutation.mutateAsync({ id: editId, draft }); + } else { + await createMutation.mutateAsync(draft); + } + } + + async function deleteProtocol(id: string) { + await deleteMutation.mutateAsync(id); + } + + // Acknowledgment is a persisted, one-way action; re-acknowledging is idempotent. + function acknowledge(id: string) { + if (!acknowledgedIds.has(id)) { + acknowledgeProtocol.mutate(id); + } + } + + function toggleExpanded(id: string) { + setExpandedId((current) => (current === id ? null : id)); + } + + return { + canManage, + protocols, + acknowledgedIds, + expandedId, + showNewForm, + editId, + draft, + formError, + isLoading: protocolsQuery.isLoading, + error: protocolsQuery.error, + isSaving: createMutation.isPending || updateMutation.isPending, + isDeleting: deleteMutation.isPending, + openNewForm, + startEditing, + cancelForm: resetForm, + updateDraftField, + updateListItem, + addListItem, + removeListItem, + saveDraft, + deleteProtocol, + acknowledge, + toggleExpanded, + }; +} diff --git a/frontend/src/business/safety-protocols/mappers.test.ts b/frontend/src/business/safety-protocols/mappers.test.ts new file mode 100644 index 0000000..c204539 --- /dev/null +++ b/frontend/src/business/safety-protocols/mappers.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + toSafetyProtocolMutationDto, + toSafetyProtocolViewModel, +} from '@/business/safety-protocols/mappers'; +import type { SafetyProtocolFormInput } from '@/business/safety-protocols/types'; +import { + POLICY_DATE_NOT_RECORDED_LABEL, + POLICY_DOCUMENT_PAGE_CATEGORY, + POLICY_UPDATED_BY_LABEL, +} from '@/shared/constants/policies'; +import type { PolicyDocumentDto } from '@/shared/types/policyDocuments'; + +function dto(overrides: Partial = {}): PolicyDocumentDto { + return { + id: 'safety-1', + title: 'Fire Drill Procedures', + body: null, + category: 'safety_protocol', + tag: 'fire', + author: 'Dr. Sarah Williams', + steps: ['Hear alarm', 'Line up', 'Walk to assembly point'], + autism_considerations: ['Use visual card', 'Offer headphones'], + version: 1, + active: true, + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2026-06-07T08:00:00.000Z', + updatedAt: '2026-06-08T09:30:00.000Z', + ...overrides, + }; +} + +describe('safety protocol mappers', () => { + it('maps a safety_protocol document into the view model (steps + considerations)', () => { + expect(toSafetyProtocolViewModel(dto())).toEqual({ + id: 'safety-1', + title: 'Fire Drill Procedures', + tag: 'fire', + steps: ['Hear alarm', 'Line up', 'Walk to assembly point'], + autismConsiderations: ['Use visual card', 'Offer headphones'], + version: 1, + lastUpdated: '2026-06-08', + author: 'Dr. Sarah Williams', + }); + }); + + it('defaults null arrays/author/date to empty values', () => { + expect( + toSafetyProtocolViewModel( + dto({ steps: null, autism_considerations: null, author: null, updatedAt: '' }), + ), + ).toEqual({ + id: 'safety-1', + title: 'Fire Drill Procedures', + tag: 'fire', + steps: [], + autismConsiderations: [], + version: 1, + lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL, + author: POLICY_UPDATED_BY_LABEL, + }); + }); + + it('maps form input into a safety_protocol mutation DTO (trims, drops blanks)', () => { + const input: SafetyProtocolFormInput = { + title: ' Lockdown ', + tag: 'lockdown', + steps: [' Lock door ', '', 'Cover window'], + autismConsiderations: ['Pre-teach', ' '], + }; + expect(toSafetyProtocolMutationDto(input)).toEqual({ + title: 'Lockdown', + category: POLICY_DOCUMENT_PAGE_CATEGORY.safetyProtocols, + tag: 'lockdown', + steps: ['Lock door', 'Cover window'], + autism_considerations: ['Pre-teach'], + }); + }); +}); diff --git a/frontend/src/business/safety-protocols/mappers.ts b/frontend/src/business/safety-protocols/mappers.ts new file mode 100644 index 0000000..da9e80e --- /dev/null +++ b/frontend/src/business/safety-protocols/mappers.ts @@ -0,0 +1,49 @@ +import { + POLICY_DATE_NOT_RECORDED_LABEL, + POLICY_DOCUMENT_PAGE_CATEGORY, + POLICY_UPDATED_BY_LABEL, +} from '@/shared/constants/policies'; +import type { + PolicyDocumentMutationDto, + PolicyDocumentDto, +} from '@/shared/types/policyDocuments'; +import type { + SafetyProtocolFormInput, + SafetyProtocolViewModel, +} from '@/business/safety-protocols/types'; + +function toDateOnly(value: string | null): string { + if (!value) { + return POLICY_DATE_NOT_RECORDED_LABEL; + } + return value.split('T')[0] || POLICY_DATE_NOT_RECORDED_LABEL; +} + +export function toSafetyProtocolViewModel( + dto: PolicyDocumentDto, +): SafetyProtocolViewModel { + return { + id: dto.id, + title: dto.title || '', + tag: dto.tag, + steps: dto.steps ?? [], + autismConsiderations: dto.autism_considerations ?? [], + version: dto.version, + lastUpdated: toDateOnly(dto.updatedAt), + author: dto.author || POLICY_UPDATED_BY_LABEL, + }; +} + +export function toSafetyProtocolMutationDto( + input: SafetyProtocolFormInput, +): PolicyDocumentMutationDto { + return { + title: input.title.trim(), + category: POLICY_DOCUMENT_PAGE_CATEGORY.safetyProtocols, + tag: input.tag, + steps: input.steps.map((step) => step.trim()).filter(Boolean), + autism_considerations: input.autismConsiderations + .map((item) => item.trim()) + .filter(Boolean), + }; +} diff --git a/frontend/src/business/safety-protocols/selectors.test.ts b/frontend/src/business/safety-protocols/selectors.test.ts new file mode 100644 index 0000000..86a5457 --- /dev/null +++ b/frontend/src/business/safety-protocols/selectors.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + canManageSafetyProtocols, + isSafetyDraftValid, +} from '@/business/safety-protocols/selectors'; +import type { SafetyProtocolDraft } from '@/business/safety-protocols/types'; + +const validDraft: SafetyProtocolDraft = { + title: 'Fire Drill', + tag: 'fire', + steps: ['', 'Walk to assembly point'], + autismConsiderations: [''], +}; + +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); + expect(isSafetyDraftValid({ ...validDraft, steps: ['', ' '] })).toBe(false); + }); +}); diff --git a/frontend/src/business/safety-protocols/selectors.ts b/frontend/src/business/safety-protocols/selectors.ts new file mode 100644 index 0000000..6e701f0 --- /dev/null +++ b/frontend/src/business/safety-protocols/selectors.ts @@ -0,0 +1,17 @@ +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-protocols/types.ts b/frontend/src/business/safety-protocols/types.ts new file mode 100644 index 0000000..b19e38d --- /dev/null +++ b/frontend/src/business/safety-protocols/types.ts @@ -0,0 +1,33 @@ +/** A safety protocol rendered from a `policy_documents` row (safety_protocol). */ +export interface SafetyProtocolViewModel { + readonly id: string; + readonly title: string; + /** Sub-category tag → drives the static card icon. */ + readonly tag: string | null; + readonly steps: readonly string[]; + readonly autismConsiderations: readonly string[]; + readonly version: number; + readonly lastUpdated: string; + readonly author: string; +} + +export interface SafetyProtocolFormInput { + readonly title: string; + readonly tag: string; + readonly steps: readonly string[]; + readonly autismConsiderations: readonly string[]; +} + +/** + * Mutable authoring draft. `steps` / `autismConsiderations` grow and shrink + * dynamically — each protocol can carry a different number of either. + */ +export interface SafetyProtocolDraft { + title: string; + tag: string; + steps: string[]; + autismConsiderations: string[]; +} + +/** The two dynamic string-list fields on the draft. */ +export type SafetyProtocolListKey = 'steps' | 'autismConsiderations'; diff --git a/frontend/src/business/safety-quiz/mappers.test.ts b/frontend/src/business/safety-quiz/mappers.test.ts index 205cd3a..77daae1 100644 --- a/frontend/src/business/safety-quiz/mappers.test.ts +++ b/frontend/src/business/safety-quiz/mappers.test.ts @@ -23,7 +23,7 @@ function createResult(overrides: Partial = {}): SafetyQuizR total_questions: 5, answers: [0, 1, 2, 3, 0], user_name: 'Ava Lee', - user_role: 'para', + user_role: 'support_staff', completed_at: '2026-06-08T12:00:00.000Z', organizationId: 'org-1', campusId: 'campus-1', diff --git a/frontend/src/business/safety-quiz/mappers.ts b/frontend/src/business/safety-quiz/mappers.ts index da22bbb..18bbb67 100644 --- a/frontend/src/business/safety-quiz/mappers.ts +++ b/frontend/src/business/safety-quiz/mappers.ts @@ -2,7 +2,7 @@ import { SafetyQuizResultCreateDto, SafetyQuizResultDto } from '@/shared/types/s import { SafetyQuizComplianceRow, SafetyQuizSubmission } from '@/business/safety-quiz/types'; function toRoleLabel(role: string): string { - if (role === 'para') { + if (role === 'support_staff') { return 'Para'; } @@ -14,7 +14,7 @@ function toRoleLabel(role: string): string { return 'Superintendent'; } - if (role === 'office') { + if (role === 'office_manager') { return 'Office'; } diff --git a/frontend/src/business/staff-attendance/selectors.test.ts b/frontend/src/business/staff-attendance/selectors.test.ts index 56ae4a0..a1dde41 100644 --- a/frontend/src/business/staff-attendance/selectors.test.ts +++ b/frontend/src/business/staff-attendance/selectors.test.ts @@ -10,9 +10,9 @@ import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance const records: readonly StaffAttendanceRecordViewModel[] = [ { id: '1', date: '2026-06-08', status: 'present', note: null, userName: 'Ava Lee', userRole: 'teacher' }, { id: '2', date: '2026-06-08', status: 'late', note: null, userName: 'Ava Lee', userRole: 'teacher' }, - { id: '3', date: '2026-06-08', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, - { id: '4', date: '2026-06-09', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, - { id: '5', date: '2026-06-10', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'para' }, + { id: '3', date: '2026-06-08', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'support_staff' }, + { id: '4', date: '2026-06-09', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'support_staff' }, + { id: '5', date: '2026-06-10', status: 'absent', note: null, userName: 'Ben Kim', userRole: 'support_staff' }, ]; describe('staff attendance selectors', () => { diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 2172ab3..fec3378 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -19,13 +19,11 @@ export function useTopBarPage({ userName, campusInfo, toggleSidebar, - isAuthenticated, profile, signOut: signOutAction, }: UseTopBarPageOptions): TopBarPage { const [showProfileMenu, setShowProfileMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); - const [showSignInModal, setShowSignInModal] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [signOutError, setSignOutError] = useState(null); const notifications = EMPTY_TOP_BAR_NOTIFICATIONS; @@ -44,14 +42,12 @@ export function useTopBarPage({ userRole, userName, campusInfo, - isAuthenticated, profileRoleLabel: profile ? getTopBarRoleLabel(profile.role) : getTopBarRoleLabel(userRole), roleLabel: getTopBarRoleLabel(userRole), initials: getTopBarInitials(userName), campusLabel: getTopBarCampusLabel(campusInfo), showProfileMenu, showNotifications, - showSignInModal, searchQuery, signOutError, notifications, @@ -61,8 +57,6 @@ export function useTopBarPage({ closeProfileMenu: () => setShowProfileMenu(false), toggleNotifications: () => setShowNotifications((current) => !current), closeNotifications: () => setShowNotifications(false), - openSignInModal: () => setShowSignInModal(true), - closeSignInModal: () => setShowSignInModal(false), setSearchQuery, signOut, }; diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 1c9b6bd..0a7e563 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -19,8 +19,8 @@ describe('top bar selectors', () => { }); it('uses shared auth role labels', () => { - expect(getTopBarRoleLabel('para')).toBe('Support Staff'); - expect(getTopBarRoleLabel('office')).toBe('Office Manager'); + expect(getTopBarRoleLabel('support_staff')).toBe('Support Staff'); + expect(getTopBarRoleLabel('office_manager')).toBe('Office Manager'); }); it('counts unread notifications', () => { diff --git a/frontend/src/business/top-bar/types.ts b/frontend/src/business/top-bar/types.ts index 619d03e..c4f2183 100644 --- a/frontend/src/business/top-bar/types.ts +++ b/frontend/src/business/top-bar/types.ts @@ -19,7 +19,6 @@ export interface TopBarNotification { } export interface UseTopBarPageOptions extends TopBarProps { - readonly isAuthenticated: boolean; readonly profile: AuthSessionState['profile']; readonly signOut: AuthSessionState['signOut']; } @@ -28,14 +27,12 @@ export interface TopBarPage { readonly userRole: UserRole; readonly userName: string; readonly campusInfo?: CampusInfo; - readonly isAuthenticated: boolean; readonly profileRoleLabel: string; readonly roleLabel: string; readonly initials: string; readonly campusLabel: string; readonly showProfileMenu: boolean; readonly showNotifications: boolean; - readonly showSignInModal: boolean; readonly searchQuery: string; readonly signOutError: string | null; readonly notifications: readonly TopBarNotification[]; @@ -45,8 +42,6 @@ export interface TopBarPage { readonly closeProfileMenu: () => void; readonly toggleNotifications: () => void; readonly closeNotifications: () => void; - readonly openSignInModal: () => void; - readonly closeSignInModal: () => void; readonly setSearchQuery: (value: string) => void; readonly signOut: () => Promise; } diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 253be2e..49a20e8 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -3,28 +3,24 @@ import { Outlet } from 'react-router-dom'; import { useAuth } from '@/contexts/useAuth'; import { useIsMobile } from '@/hooks/use-mobile'; import { useAppShell } from '@/business/app-shell/hooks'; -import { GuestBanner } from '@/components/app-shell/GuestBanner'; import { AppFooter } from '@/components/app-shell/AppFooter'; import Sidebar from '@/components/frameworks/Sidebar'; import TopBar from '@/components/frameworks/TopBar'; -import SignInModal from '@/components/frameworks/SignInModal'; import { Loader2 } from 'lucide-react'; const AppLayout: React.FC = () => { - const { isAuthenticated, profile, loading: authLoading } = useAuth(); + const { profile, loading: authLoading } = useAuth(); const isMobile = useIsMobile(); const { mobileOverlayVisible, sidebarProps, topBarProps, shellOutletContext, - guestBannerProps, footerProps, - signInModalProps, setMobileSidebarOpen, - } = useAppShell({ isAuthenticated, profile, isMobile }); + } = useAppShell({ profile, isMobile }); // Loading state if (authLoading) { @@ -59,10 +55,6 @@ const AppLayout: React.FC = () => {
- {!isAuthenticated && ( - - )} -
@@ -70,9 +62,6 @@ const AppLayout: React.FC = () => {
- - {/* Sign In Modal */} - ); }; diff --git a/frontend/src/components/app-shell/AppFooter.tsx b/frontend/src/components/app-shell/AppFooter.tsx index 70bde89..6b34aa2 100644 --- a/frontend/src/components/app-shell/AppFooter.tsx +++ b/frontend/src/components/app-shell/AppFooter.tsx @@ -7,10 +7,8 @@ import { } from '@/shared/constants/footerNavigation'; export function AppFooter({ - isAuthenticated, userName, userRole, - currentGuestPreviewRole, setCurrentModule, }: AppFooterProps) { return ( @@ -77,12 +75,9 @@ export function AppFooter({ FRAMEworks © 2026 - Built for autism-focused school communities

- + - {isAuthenticated - ? `Signed in as ${userName} (${userRole})` - : `Browsing as Guest (${currentGuestPreviewRole.label})` - } + {`Signed in as ${userName} (${userRole})`}
diff --git a/frontend/src/components/app-shell/GuestBanner.tsx b/frontend/src/components/app-shell/GuestBanner.tsx deleted file mode 100644 index 1f9b6c5..0000000 --- a/frontend/src/components/app-shell/GuestBanner.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { CheckCircle2, ChevronDown, Eye, LogIn } from 'lucide-react'; -import type { GuestBannerProps } from '@/business/app-shell/types'; - -export function GuestBanner({ - guestPreviewRole, - guestPreviewRoles, - currentGuestPreviewRole, - showGuestRolePicker, - setGuestPreviewRole, - setShowGuestRolePicker, - onSignInClick, -}: GuestBannerProps) { - return ( -
-
-
-
- -
-
-

You're browsing as a Guest

-

- Explore all modules freely. Sign in to save your progress and get a personalized experience. -

-
-
-
-
- - {showGuestRolePicker && ( - <> -
setShowGuestRolePicker(false)} /> -
-

- Switch Guest View -

- {guestPreviewRoles.map((role) => ( - - ))} -
- - )} -
- - -
-
-
- ); -} diff --git a/frontend/src/components/auth/PermissionGate.tsx b/frontend/src/components/auth/PermissionGate.tsx new file mode 100644 index 0000000..b4ed8d9 --- /dev/null +++ b/frontend/src/components/auth/PermissionGate.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import { usePermissions } from '@/hooks/usePermissions'; +import type { PermissionName } from '@/shared/auth/permissions'; + +interface PermissionGateProps { + /** Require this single permission. */ + readonly permission?: PermissionName; + /** Require at least one of these permissions. */ + readonly anyOf?: readonly PermissionName[]; + /** Require all of these permissions. */ + readonly allOf?: readonly PermissionName[]; + /** Rendered when the user is not permitted (defaults to nothing). */ + readonly fallback?: ReactNode; + readonly children: ReactNode; +} + +/** + * Renders its children only when the current user holds the required + * permission(s) (Workstream 3 §3.6). UX-only — the backend still enforces every + * request; this just hides affordances the backend would reject. `globalAccess` + * roles pass (handled by the selectors). + */ +export function PermissionGate({ + permission, + anyOf, + allOf, + fallback = null, + children, +}: PermissionGateProps) { + const permissions = usePermissions(); + + const allowed = + (permission === undefined || permissions.has(permission)) && + (anyOf === undefined || permissions.hasAny(anyOf)) && + (allOf === undefined || permissions.hasAll(allOf)); + + return <>{allowed ? children : fallback}; +} diff --git a/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx index e13f5b9..be6b37f 100644 --- a/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx +++ b/frontend/src/components/classroom-support/ClassroomStrategyCard.tsx @@ -26,7 +26,7 @@ export function ClassroomStrategyCard({ onSelect(strategy); } - function handleKeyDown(event: KeyboardEvent) { + function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openStrategy(); @@ -34,13 +34,14 @@ export function ClassroomStrategyCard({ } return ( -
+
+ - ))} +
+ )} +
+ {soundGroups.map((group) => { + if (group.sounds.length === 0) { + return null; + } + + return ( +
+

{group.label}

+ {group.sounds.map((sound) => { + const isSelected = selectedSound?.key === sound.key; + const deletableId = sound.canDelete ? sound.audioFileId : null; + return ( +
+ + + {deletableId && ( + + )} +
+ ); + })} +
+ ); + })}
diff --git a/frontend/src/components/community-service/CommunityOrganizationCard.tsx b/frontend/src/components/community-service/CommunityOrganizationCard.tsx index 158b652..69cc18d 100644 --- a/frontend/src/components/community-service/CommunityOrganizationCard.tsx +++ b/frontend/src/components/community-service/CommunityOrganizationCard.tsx @@ -24,7 +24,7 @@ export function CommunityOrganizationCard({ onToggleExpanded, onToggleSaved, }: CommunityOrganizationCardProps) { - const handleCardKeyDown = (event: KeyboardEvent) => { + const handleCardKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onToggleExpanded(); @@ -35,13 +35,15 @@ export function CommunityOrganizationCard({
-
+
+ + +
-
diff --git a/frontend/src/components/frameworks/ClassroomTimer.tsx b/frontend/src/components/frameworks/ClassroomTimer.tsx index 3157760..75a5065 100644 --- a/frontend/src/components/frameworks/ClassroomTimer.tsx +++ b/frontend/src/components/frameworks/ClassroomTimer.tsx @@ -8,9 +8,14 @@ import { TimerSettingsPanel } from '@/components/classroom-timer/TimerSettingsPa import { TimerTips } from '@/components/classroom-timer/TimerTips'; import { StatePanel } from '@/components/ui/state-panel'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import type { UserRole } from '@/shared/types/app'; -const ClassroomTimer = () => { - const timer = useClassroomTimer(); +interface ClassroomTimerProps { + readonly userRole: UserRole; +} + +const ClassroomTimer = ({ userRole }: ClassroomTimerProps) => { + const timer = useClassroomTimer(userRole); const contentErrorMessage = getOptionalErrorMessage(timer.state.contentError); if (timer.state.isContentLoading) { diff --git a/frontend/src/components/frameworks/SignInModal.tsx b/frontend/src/components/frameworks/SignInModal.tsx index 897ee3b..ffaf0dd 100644 --- a/frontend/src/components/frameworks/SignInModal.tsx +++ b/frontend/src/components/frameworks/SignInModal.tsx @@ -1,12 +1,14 @@ import { useAuthModalWorkflow } from '@/business/auth/hooks'; -import { AuthModeToggle } from '@/components/sign-in-modal/AuthModeToggle'; import { AuthStatusMessage } from '@/components/sign-in-modal/AuthStatusMessage'; import { SignInForm } from '@/components/sign-in-modal/SignInForm'; import { SignInModalFrame } from '@/components/sign-in-modal/SignInModalFrame'; import { SignInModalHeader } from '@/components/sign-in-modal/SignInModalHeader'; -import { SignupForm } from '@/components/sign-in-modal/SignupForm'; import { useAuth } from '@/contexts/useAuth'; -import type { SignInModalProps } from '@/business/app-shell/types'; + +interface SignInModalProps { + readonly isOpen: boolean; + readonly onClose: () => void; +} const SignInModal = ({ isOpen, onClose }: SignInModalProps) => { const { signIn, signUp } = useAuth(); @@ -21,14 +23,7 @@ const SignInModal = ({ isOpen, onClose }: SignInModalProps) => { - - {state.mode === 'signin' ? ( - - ) : ( - - )} - - + ); }; diff --git a/frontend/src/components/frameworks/TopBar.tsx b/frontend/src/components/frameworks/TopBar.tsx index 9748f93..cec4fbf 100644 --- a/frontend/src/components/frameworks/TopBar.tsx +++ b/frontend/src/components/frameworks/TopBar.tsx @@ -4,10 +4,9 @@ import { TopBarView } from '@/components/top-bar/TopBarView'; import { useAuth } from '@/contexts/useAuth'; const TopBar = (props: TopBarProps) => { - const { isAuthenticated, profile, signOut } = useAuth(); + const { profile, signOut } = useAuth(); const page = useTopBarPage({ ...props, - isAuthenticated, profile, signOut, }); diff --git a/frontend/src/components/parent-communication/ParentCommunicationModule.tsx b/frontend/src/components/parent-communication/ParentCommunicationModule.tsx index ca42aa7..b3a2ba2 100644 --- a/frontend/src/components/parent-communication/ParentCommunicationModule.tsx +++ b/frontend/src/components/parent-communication/ParentCommunicationModule.tsx @@ -83,7 +83,7 @@ export function ParentCommunicationModule() {

Pre-Approved Templates

{PARENT_MESSAGE_CATEGORY_FILTERS.map((category) => ( - ))} diff --git a/frontend/src/components/safety-protocols/SafetyDynamicListEditor.tsx b/frontend/src/components/safety-protocols/SafetyDynamicListEditor.tsx new file mode 100644 index 0000000..ff82c12 --- /dev/null +++ b/frontend/src/components/safety-protocols/SafetyDynamicListEditor.tsx @@ -0,0 +1,77 @@ +import { Plus, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface SafetyDynamicListEditorProps { + readonly label: string; + readonly items: readonly string[]; + readonly placeholder: string; + readonly addLabel: string; + readonly numbered?: boolean; + readonly disabled?: boolean; + readonly onChange: (index: number, value: string) => void; + readonly onAdd: () => void; + readonly onRemove: (index: number) => void; +} + +/** + * Editable list of strings with add/remove rows — used for a safety protocol's + * procedure steps and autism considerations, which vary in count per protocol. + */ +export function SafetyDynamicListEditor({ + label, + items, + placeholder, + addLabel, + numbered = false, + disabled = false, + onChange, + onAdd, + onRemove, +}: SafetyDynamicListEditorProps) { + return ( +
+ +
+ {items.map((item, index) => ( +
+ {numbered && ( + + {index + 1} + + )} + onChange(index, event.target.value)} + placeholder={placeholder} + className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-violet-500 outline-none" + /> + +
+ ))} +
+ +
+ ); +} diff --git a/frontend/src/components/safety-protocols/SafetyProtocolForm.tsx b/frontend/src/components/safety-protocols/SafetyProtocolForm.tsx new file mode 100644 index 0000000..821d7d6 --- /dev/null +++ b/frontend/src/components/safety-protocols/SafetyProtocolForm.tsx @@ -0,0 +1,108 @@ +import { Save, X } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { SAFETY_PROTOCOL_TAG_OPTIONS } from '@/shared/constants/safetyProtocols'; +import { SafetyDynamicListEditor } from '@/components/safety-protocols/SafetyDynamicListEditor'; +import type { SafetyProtocolsViewProps } from '@/components/safety-protocols/types'; + +/** Create / edit form for a safety protocol (manager-only). */ +export function SafetyProtocolForm({ workflow }: SafetyProtocolsViewProps) { + const isEditing = workflow.editId !== null; + + return ( +
+
+

+ {isEditing ? 'Edit Safety Protocol' : 'New Safety Protocol'} +

+ +
+ +
+ + workflow.updateDraftField({ title: event.target.value })} + placeholder="e.g. Fire Drill Procedures" + className="w-full mt-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-800 placeholder-gray-400 focus:ring-2 focus:ring-violet-500 outline-none" + /> +
+ +
+ + workflow.updateDraftField({ tag: event.target.value })} + className="w-full mt-1 px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-800 focus:ring-2 focus:ring-violet-500 outline-none" + > + {SAFETY_PROTOCOL_TAG_OPTIONS.map((option) => ( + + ))} + +
+ + workflow.updateListItem('steps', index, value)} + onAdd={() => workflow.addListItem('steps')} + onRemove={(index) => workflow.removeListItem('steps', index)} + /> + + + workflow.updateListItem('autismConsiderations', index, value) + } + onAdd={() => workflow.addListItem('autismConsiderations')} + onRemove={(index) => workflow.removeListItem('autismConsiderations', index)} + /> + + {workflow.formError &&

{workflow.formError}

} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx b/frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx index c2dba85..a2bf050 100644 --- a/frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx +++ b/frontend/src/components/safety-protocols/SafetyProtocolsModule.tsx @@ -1,57 +1,49 @@ -import { useState } from 'react'; -import { AlertTriangle, CheckCircle, ChevronDown, ChevronUp, Heart, Shield } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; +import { AlertTriangle, CheckCircle, ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from 'lucide-react'; -import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { useSafetyProtocolsModule } from '@/business/safety-protocols/hooks'; +import { safetyStyleForTag } from '@/shared/constants/safetyProtocols'; import { Button } from '@/components/ui/button'; import { StatePanel } from '@/components/ui/state-panel'; -import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { SafetyProtocolForm } from '@/components/safety-protocols/SafetyProtocolForm'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import type { UserRole } from '@/shared/types/app'; -type SafetyProtocolIconId = 'fire' | 'shield' | 'heart'; - -interface SafetyProtocol { - readonly id: string; - readonly title: string; - readonly iconId: SafetyProtocolIconId; - readonly color: string; - readonly steps: readonly string[]; - readonly autismConsiderations: readonly string[]; +interface SafetyProtocolsModuleProps { + readonly userRole: UserRole; } -const SAFETY_PROTOCOL_ICONS: Record = { - fire: AlertTriangle, - shield: Shield, - heart: Heart, -}; - -export function SafetyProtocolsModule() { - const protocolsQuery = useContentCatalogPayload( - CONTENT_CATALOG_TYPES.safetyProtocols, - [], - ); - const [expandedProtocol, setExpandedProtocol] = useState('fire'); - const [acknowledged, setAcknowledged] = useState>(new Set()); - const protocols = protocolsQuery.payload; - const errorMessage = getOptionalErrorMessage(protocolsQuery.error); - - const toggleAck = (id: string) => { - const next = new Set(acknowledged); - if (next.has(id)) next.delete(id); else next.add(id); - setAcknowledged(next); - }; +export function SafetyProtocolsModule({ userRole }: SafetyProtocolsModuleProps) { + const workflow = useSafetyProtocolsModule(userRole); + const errorMessage = getOptionalErrorMessage(workflow.error); + const { protocols, acknowledgedIds, canManage } = workflow; + const isFormOpen = workflow.showNewForm || workflow.editId !== null; return (
-
-

-
- Safety Protocols -

-

Clear, visual safety standards - always accessible, always current

+
+
+

+
+ Safety Protocols +

+

Clear, visual safety standards - always accessible, always current

+
+ {canManage && !isFormOpen && ( + + )}
+ + {workflow.showNewForm && } +
- {protocolsQuery.isLoading && ( + {workflow.isLoading && ( Loading safety protocols... @@ -61,45 +53,74 @@ export function SafetyProtocolsModule() { {errorMessage} )} - {!protocolsQuery.isLoading && !errorMessage && protocols.length === 0 && ( + {!workflow.isLoading && !errorMessage && protocols.length === 0 && ( No safety protocols are published yet. )} - {!protocolsQuery.isLoading && !errorMessage && protocols.map((protocol) => { - const ProtocolIcon = SAFETY_PROTOCOL_ICONS[protocol.iconId]; + {!workflow.isLoading && !errorMessage && protocols.map((protocol) => { + if (workflow.editId === protocol.id) { + return ; + } + + const { icon: ProtocolIcon, color } = safetyStyleForTag(protocol.tag); + const isAcknowledged = acknowledgedIds.has(protocol.id); + const isExpanded = workflow.expandedId === protocol.id; return (
- - {expandedProtocol === protocol.id && ( + {isExpanded && (
-

Procedure Steps

-
{protocol.steps.map((step, index) => ( -
{index + 1}

{step}

- ))}
+ {protocol.steps.length > 0 && ( +

Procedure Steps

+
{protocol.steps.map((step, index) => ( +
{index + 1}

{step}

+ ))}
+
+ )} + {protocol.autismConsiderations.length > 0 && ( +

Autism-Specific Considerations

+
{protocol.autismConsiderations.map((tip) => ( +

{tip}

+ ))}
+
+ )} +
+ + {canManage && ( + <> + + + + )}
-

Autism-Specific Considerations

-
{protocol.autismConsiderations.map((tip) => ( -

{tip}

- ))}
-
-
)}
diff --git a/frontend/src/components/safety-protocols/types.ts b/frontend/src/components/safety-protocols/types.ts new file mode 100644 index 0000000..f425643 --- /dev/null +++ b/frontend/src/components/safety-protocols/types.ts @@ -0,0 +1,7 @@ +import type { useSafetyProtocolsModule } from '@/business/safety-protocols/hooks'; + +export type SafetyProtocolsModuleWorkflow = ReturnType; + +export interface SafetyProtocolsViewProps { + readonly workflow: SafetyProtocolsModuleWorkflow; +} diff --git a/frontend/src/components/safety-quiz/SafetyQuizView.tsx b/frontend/src/components/safety-quiz/SafetyQuizView.tsx index f9818eb..6e4e62d 100644 --- a/frontend/src/components/safety-quiz/SafetyQuizView.tsx +++ b/frontend/src/components/safety-quiz/SafetyQuizView.tsx @@ -22,7 +22,7 @@ export function SafetyQuizView({ page }: SafetyQuizViewProps) { return (
= {}): SignInModalState { + return { + mode: 'signin', + signupStep: 1, + draft: { + email: '', + password: '', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: false, + }, + loading: false, + error: null, + success: null, + selectedCampusId: '', + campuses: [], + campusesLoading: false, + campusesError: null, + ...overrides, + }; +} + +function createMockActions(overrides: Partial = {}): SignInModalActions { + return { + updateDraft: vi.fn(), + setRole: vi.fn(), + setCampus: vi.fn(), + setShowPassword: vi.fn(), + handleSignIn: vi.fn().mockResolvedValue(undefined), + goToNextStep: vi.fn(), + goToPreviousStep: vi.fn(), + handleClose: vi.fn(), + switchMode: vi.fn(), + getNextSignupStep: vi.fn(), + ...overrides, + }; +} + +describe('SignInForm', () => { + it('renders email and password fields', () => { + const state = createMockState(); + const actions = createMockActions(); + + render(); + + expect(screen.getByPlaceholderText(/you@school\.edu/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument(); + }); + + it('renders sign in button', () => { + const state = createMockState(); + const actions = createMockActions(); + + render(); + + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + it('disables submit button when loading', () => { + const state = createMockState({ loading: true }); + const actions = createMockActions(); + + render(); + + expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled(); + }); + + it('shows loading text when loading', () => { + const state = createMockState({ loading: true }); + const actions = createMockActions(); + + render(); + + expect(screen.getByText(/signing in/i)).toBeInTheDocument(); + }); + + it('calls handleSignIn on form submit', async () => { + const user = userEvent.setup(); + const state = createMockState({ + draft: { + email: 'test@example.com', + password: 'password123', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: false, + }, + }); + const handleSignIn = vi.fn().mockResolvedValue(undefined); + const actions = createMockActions({ handleSignIn }); + + render(); + + await user.click(screen.getByRole('button', { name: /sign in/i })); + + expect(handleSignIn).toHaveBeenCalledTimes(1); + }); + + it('calls updateDraft when email input changes', async () => { + const user = userEvent.setup(); + const state = createMockState(); + const updateDraft = vi.fn(); + const actions = createMockActions({ updateDraft }); + + render(); + + const emailInput = screen.getByPlaceholderText(/you@school\.edu/i); + await user.type(emailInput, 'new@example.com'); + + expect(updateDraft).toHaveBeenCalled(); + expect(updateDraft).toHaveBeenCalledWith(expect.objectContaining({ email: expect.any(String) })); + }); + + it('calls updateDraft when password input changes', async () => { + const user = userEvent.setup(); + const state = createMockState(); + const updateDraft = vi.fn(); + const actions = createMockActions({ updateDraft }); + + render(); + + const passwordInput = screen.getByPlaceholderText(/enter your password/i); + await user.type(passwordInput, 'secret'); + + expect(updateDraft).toHaveBeenCalled(); + expect(updateDraft).toHaveBeenCalledWith(expect.objectContaining({ password: expect.any(String) })); + }); + + it('toggles password visibility when toggle button is clicked', async () => { + const user = userEvent.setup(); + const state = createMockState(); + const setShowPassword = vi.fn(); + const actions = createMockActions({ setShowPassword }); + + render(); + + const toggleButton = screen.getByRole('button', { name: /show password/i }); + await user.click(toggleButton); + + expect(setShowPassword).toHaveBeenCalledWith(true); + }); + + it('shows hide password button when password is visible', async () => { + const user = userEvent.setup(); + const state = createMockState({ + draft: { + email: '', + password: '', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: true, + }, + }); + const setShowPassword = vi.fn(); + const actions = createMockActions({ setShowPassword }); + + render(); + + const toggleButton = screen.getByRole('button', { name: /hide password/i }); + await user.click(toggleButton); + + expect(setShowPassword).toHaveBeenCalledWith(false); + }); + + it('displays current email value from state', () => { + const state = createMockState({ + draft: { + email: 'existing@example.com', + password: '', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: false, + }, + }); + const actions = createMockActions(); + + render(); + + expect(screen.getByPlaceholderText(/you@school\.edu/i)).toHaveValue('existing@example.com'); + }); + + it('displays current password value from state', () => { + const state = createMockState({ + draft: { + email: '', + password: 'currentpass', + confirmPassword: '', + fullName: '', + role: 'teacher', + campus: '', + showPassword: false, + }, + }); + const actions = createMockActions(); + + render(); + + expect(screen.getByPlaceholderText(/enter your password/i)).toHaveValue('currentpass'); + }); + + it('has required attribute on email field', () => { + const state = createMockState(); + const actions = createMockActions(); + + render(); + + expect(screen.getByPlaceholderText(/you@school\.edu/i)).toBeRequired(); + }); + + it('has required attribute on password field', () => { + const state = createMockState(); + const actions = createMockActions(); + + render(); + + expect(screen.getByPlaceholderText(/enter your password/i)).toBeRequired(); + }); +}); diff --git a/frontend/src/components/sign-in-modal/SignupRoleStep.tsx b/frontend/src/components/sign-in-modal/SignupRoleStep.tsx index 305dad5..e14a5cc 100644 --- a/frontend/src/components/sign-in-modal/SignupRoleStep.tsx +++ b/frontend/src/components/sign-in-modal/SignupRoleStep.tsx @@ -15,8 +15,8 @@ const ROLE_OPTIONS: readonly { readonly icon: ReactNode; }[] = [ { value: 'teacher', label: 'Teacher', desc: 'Classroom educator', color: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', icon: }, - { value: 'para', label: 'Support Staff', desc: 'Paraprofessional', color: 'border-blue-500/30 bg-blue-500/10 text-blue-400', icon: }, - { value: 'office', label: 'Office Manager', desc: 'Administrative staff', color: 'border-amber-500/30 bg-amber-500/10 text-amber-400', icon: }, + { value: 'support_staff', label: 'Support Staff', desc: 'Paraprofessional', color: 'border-blue-500/30 bg-blue-500/10 text-blue-400', icon: }, + { value: 'office_manager', label: 'Office Manager', desc: 'Administrative staff', color: 'border-amber-500/30 bg-amber-500/10 text-amber-400', icon: }, { value: 'director', label: 'Director', desc: 'Campus leadership', color: 'border-purple-500/30 bg-purple-500/10 text-purple-400', icon: }, { value: 'superintendent', label: 'Superintendent', desc: 'District-wide oversight', color: 'border-rose-500/30 bg-rose-500/10 text-rose-400', icon: }, ]; diff --git a/frontend/src/components/sign-language/SignLanguageFilters.tsx b/frontend/src/components/sign-language/SignLanguageFilters.tsx index 9f3b39d..918d007 100644 --- a/frontend/src/components/sign-language/SignLanguageFilters.tsx +++ b/frontend/src/components/sign-language/SignLanguageFilters.tsx @@ -34,7 +34,7 @@ export function SignLanguageFilters({ value={filters.searchQuery} onChange={(event) => onSearchChange(event.target.value)} placeholder="Search signs..." - className="pl-10 pr-10 py-2.5 border-gray-200 rounded-xl bg-white focus-visible:ring-indigo-300 focus-visible:border-indigo-400" + className="pl-10 pr-10 py-2.5 border-gray-200 rounded-xl bg-white text-gray-900 placeholder:text-gray-500 focus-visible:ring-indigo-300 focus-visible:border-indigo-400" /> {filters.searchQuery && ( - -
+
+
+ + +
-
- {page.isAuthenticated ? ( - <> - {page.signOutError && ( -
- {page.signOutError} -
- )} +
+ {page.signOutError && ( +
+ {page.signOutError} +
+ )} - - - - - - ) : ( - - )} -
-
- - - + + + + +
+ ); } diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx index 46c56c4..92f41b7 100644 --- a/frontend/src/components/ui/chart.tsx +++ b/frontend/src/components/ui/chart.tsx @@ -3,6 +3,11 @@ import * as RechartsPrimitive from "recharts" import { cn } from "@/lib/utils" +/** CSSProperties extended to allow CSS custom properties (variables) */ +interface CSSPropertiesWithVars extends React.CSSProperties { + [key: `--${string}`]: string | number | null | undefined; +} + // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const @@ -218,12 +223,10 @@ const ChartTooltipContent = React.forwardRef< "my-0.5": nestLabel && indicator === "dashed", } )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } + style={{ + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as CSSPropertiesWithVars} /> ) )} diff --git a/frontend/src/components/zones-of-regulation/ZoneDetailPanel.tsx b/frontend/src/components/zones-of-regulation/ZoneDetailPanel.tsx index d42306d..2091626 100644 --- a/frontend/src/components/zones-of-regulation/ZoneDetailPanel.tsx +++ b/frontend/src/components/zones-of-regulation/ZoneDetailPanel.tsx @@ -41,7 +41,7 @@ export function ZoneDetailPanel({

{zone.name}

-

{zone.description}

+

{zone.description}

diff --git a/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx b/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx index 0c55013..6d8bddc 100644 --- a/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx +++ b/frontend/src/components/zones-of-regulation/ZoneOverviewCard.tsx @@ -44,7 +44,7 @@ export function ZoneOverviewCard({ {zone.color[0].toUpperCase()} {zone.name} - {zone.description} + {zone.description} ); } diff --git a/frontend/src/hooks/usePermissions.test.tsx b/frontend/src/hooks/usePermissions.test.tsx new file mode 100644 index 0000000..b15fef3 --- /dev/null +++ b/frontend/src/hooks/usePermissions.test.tsx @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePermissions } from './usePermissions'; +import type { CurrentUser } from '@/shared/types/auth'; + +const mockUser: CurrentUser = { + id: 'user-1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + permissions: ['READ_CAMPUSES', 'READ_DASHBOARD', 'READ_FRAME'], + app_role: { name: 'teacher', globalAccess: false }, +}; + +const mockGlobalUser: CurrentUser = { + id: 'admin-1', + email: 'admin@example.com', + firstName: 'Admin', + lastName: 'User', + permissions: [], + app_role: { name: 'super_admin', globalAccess: true }, +}; + +// Mock the auth context +vi.mock('@/contexts/useAuth', () => ({ + useAuth: vi.fn(), +})); + +import { useAuth } from '@/contexts/useAuth'; + +describe('usePermissions', () => { + it('returns empty permissions array for null user', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + profile: null, + loading: false, + isAuthenticated: false, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.permissions).toEqual([]); + }); + + it('returns user permissions array when user exists', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.permissions).toEqual(['READ_CAMPUSES', 'READ_DASHBOARD', 'READ_FRAME']); + }); + + describe('has()', () => { + it('returns true for granted permission', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.has('READ_CAMPUSES')).toBe(true); + }); + + it('returns false for missing permission', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.has('DELETE_ORGANIZATIONS')).toBe(false); + }); + + it('returns true for any permission when user has globalAccess', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockGlobalUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.has('DELETE_ORGANIZATIONS')).toBe(true); + expect(result.current.has('UPDATE_USERS')).toBe(true); + }); + + it('returns false for null user', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + profile: null, + loading: false, + isAuthenticated: false, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.has('READ_CAMPUSES')).toBe(false); + }); + }); + + describe('hasAny()', () => { + it('returns true if any permission matches', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAny(['READ_CAMPUSES', 'DELETE_ORGANIZATIONS'])).toBe(true); + }); + + it('returns false if no permissions match', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAny(['DELETE_ORGANIZATIONS', 'UPDATE_USERS'])).toBe(false); + }); + + it('returns true for any permissions when user has globalAccess', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockGlobalUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAny(['DELETE_ORGANIZATIONS', 'UPDATE_USERS'])).toBe(true); + }); + + it('returns false for null user', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + profile: null, + loading: false, + isAuthenticated: false, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAny(['READ_CAMPUSES'])).toBe(false); + }); + }); + + describe('hasAll()', () => { + it('returns true if all permissions match', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAll(['READ_CAMPUSES', 'READ_DASHBOARD'])).toBe(true); + }); + + it('returns false if any permission is missing', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAll(['READ_CAMPUSES', 'DELETE_ORGANIZATIONS'])).toBe(false); + }); + + it('returns true for all permissions when user has globalAccess', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockGlobalUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAll(['DELETE_ORGANIZATIONS', 'UPDATE_USERS'])).toBe(true); + }); + + it('returns false for null user', () => { + vi.mocked(useAuth).mockReturnValue({ + user: null, + profile: null, + loading: false, + isAuthenticated: false, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result } = renderHook(() => usePermissions()); + + expect(result.current.hasAll(['READ_CAMPUSES'])).toBe(false); + }); + }); + + describe('memoization', () => { + it('returns same object reference when user does not change', () => { + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + profile: null, + loading: false, + isAuthenticated: true, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + updateProfile: vi.fn(), + }); + + const { result, rerender } = renderHook(() => usePermissions()); + + const firstResult = result.current; + rerender(); + const secondResult = result.current; + + expect(firstResult).toBe(secondResult); + }); + }); +}); diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts new file mode 100644 index 0000000..b0df3d2 --- /dev/null +++ b/frontend/src/hooks/usePermissions.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useAuth } from '@/contexts/useAuth'; +import { + hasPermission, + hasAnyPermission, + hasAllPermissions, +} from '@/business/auth/permissions'; +import type { PermissionName } from '@/shared/auth/permissions'; + +export interface UsePermissions { + readonly permissions: readonly string[]; + readonly has: (permission: PermissionName) => boolean; + readonly hasAny: (permissions: readonly PermissionName[]) => boolean; + readonly hasAll: (permissions: readonly PermissionName[]) => boolean; +} + +export function usePermissions(): UsePermissions { + const { user } = useAuth(); + + return useMemo( + () => ({ + permissions: user?.permissions ?? [], + has: (permission: PermissionName) => hasPermission(user, permission), + hasAny: (permissions: readonly PermissionName[]) => + hasAnyPermission(user, permissions), + hasAll: (permissions: readonly PermissionName[]) => + hasAllPermissions(user, permissions), + }), + [user], + ); +} diff --git a/frontend/src/pages/modules/ClassroomTimerPage.tsx b/frontend/src/pages/modules/ClassroomTimerPage.tsx index 054437a..f540c57 100644 --- a/frontend/src/pages/modules/ClassroomTimerPage.tsx +++ b/frontend/src/pages/modules/ClassroomTimerPage.tsx @@ -1,5 +1,8 @@ +import { useShellOutletContext } from '@/app/shellOutletContext'; import ClassroomTimer from '@/components/frameworks/ClassroomTimer'; export default function ClassroomTimerPage() { - return ; + const shell = useShellOutletContext(); + + return ; } diff --git a/frontend/src/pages/modules/SafetyProtocolsPage.tsx b/frontend/src/pages/modules/SafetyProtocolsPage.tsx index 4a8d765..cb1f878 100644 --- a/frontend/src/pages/modules/SafetyProtocolsPage.tsx +++ b/frontend/src/pages/modules/SafetyProtocolsPage.tsx @@ -1,5 +1,8 @@ +import { useShellOutletContext } from '@/app/shellOutletContext'; import { SafetyProtocolsModule } from '@/components/frameworks/MoreModules'; export default function SafetyProtocolsPage() { - return ; + const shell = useShellOutletContext(); + + return ; } diff --git a/frontend/src/shared/api/audioFiles.test.ts b/frontend/src/shared/api/audioFiles.test.ts new file mode 100644 index 0000000..ba3f733 --- /dev/null +++ b/frontend/src/shared/api/audioFiles.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createAudioFile, + deleteAudioFile, + listAudioFiles, +} from '@/shared/api/audioFiles'; +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { AudioFileDto } from '@/shared/types/audioFiles'; + +vi.mock('@/shared/api/httpClient', () => ({ + apiRequest: vi.fn(), +})); + +const apiRequestMock = vi.mocked(apiRequest); + +describe('audioFiles API', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + describe('listAudioFiles', () => { + it('fetches all audio files', async () => { + const response: ApiListResponse = { + rows: [ + { + id: 'audio-1', + title: 'Bell Sound', + kind: 'file', + url: '/uploads/bell.mp3', + recipe: null, + is_default: false, + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + { + id: 'audio-2', + title: 'Chime Recipe', + kind: 'recipe', + url: null, + recipe: { + voices: [ + { + waveform: 'sine', + notes: [{ freq: 440, startAt: 0, duration: 0.5, gain: 0.8 }], + }, + ], + }, + is_default: true, + organizationId: null, + campusId: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ], + count: 2, + }; + apiRequestMock.mockResolvedValueOnce(response); + + await expect(listAudioFiles()).resolves.toEqual(response); + + expect(apiRequestMock).toHaveBeenCalledWith('/audio_files'); + }); + }); + + describe('createAudioFile', () => { + it('creates a new audio file', async () => { + const created: AudioFileDto = { + id: 'audio-new', + title: 'New Sound', + kind: 'url', + url: 'https://example.com/sound.mp3', + recipe: null, + is_default: false, + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }; + apiRequestMock.mockResolvedValueOnce(created); + + await expect( + createAudioFile({ + kind: 'url', + title: 'New Sound', + url: 'https://example.com/sound.mp3', + }), + ).resolves.toEqual(created); + + expect(apiRequestMock).toHaveBeenCalledWith('/audio_files', { + method: 'POST', + body: { + data: { + kind: 'url', + title: 'New Sound', + url: 'https://example.com/sound.mp3', + }, + }, + }); + }); + }); + + describe('deleteAudioFile', () => { + it('deletes an audio file', async () => { + apiRequestMock.mockResolvedValueOnce(true); + + await expect(deleteAudioFile('audio-1')).resolves.toBe(true); + + expect(apiRequestMock).toHaveBeenCalledWith('/audio_files/audio-1', { + method: 'DELETE', + }); + }); + }); +}); diff --git a/frontend/src/shared/api/audioFiles.ts b/frontend/src/shared/api/audioFiles.ts new file mode 100644 index 0000000..3c2c703 --- /dev/null +++ b/frontend/src/shared/api/audioFiles.ts @@ -0,0 +1,27 @@ +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { + AudioFileDto, + AudioFileMutationDto, +} from '@/shared/types/audioFiles'; + +const AUDIO_FILES_PATH = '/audio_files'; + +export function listAudioFiles(): Promise> { + return apiRequest>(AUDIO_FILES_PATH); +} + +export function createAudioFile( + request: AudioFileMutationDto, +): Promise { + return apiRequest(AUDIO_FILES_PATH, { + method: 'POST', + body: { data: request }, + }); +} + +export function deleteAudioFile(id: string): Promise { + return apiRequest(`${AUDIO_FILES_PATH}/${id}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/shared/api/auth.test.ts b/frontend/src/shared/api/auth.test.ts index a57d176..d242a89 100644 --- a/frontend/src/shared/api/auth.test.ts +++ b/frontend/src/shared/api/auth.test.ts @@ -20,7 +20,7 @@ describe('auth API', () => { email: 'teacher@example.com', firstName: 'Teacher', lastName: 'One', - productRole: 'teacher', + app_role: { name: 'teacher' }, }; apiRequestMock.mockResolvedValueOnce(currentUser); const request: SignInRequest = { @@ -45,7 +45,7 @@ describe('auth API', () => { email: 'teacher@example.com', firstName: 'Teacher', lastName: 'One', - productRole: 'teacher', + app_role: { name: 'teacher' }, }; apiRequestMock.mockResolvedValueOnce(currentUser); @@ -70,7 +70,7 @@ describe('auth API', () => { email: 'teacher@example.com', firstName: 'Teacher', lastName: 'One', - productRole: 'teacher', + app_role: { name: 'teacher' }, }; apiRequestMock.mockResolvedValueOnce(currentUser); diff --git a/frontend/src/shared/api/communications.test.ts b/frontend/src/shared/api/communications.test.ts index 781f21e..8f0d07b 100644 --- a/frontend/src/shared/api/communications.test.ts +++ b/frontend/src/shared/api/communications.test.ts @@ -54,7 +54,7 @@ describe('communications API', () => { title: 'Safety drill', date: '2026-06-08', type: 'drill', - roles: ['teacher', 'para', 'director'], + roles: ['teacher', 'support_staff', 'director'], }; void createCommunicationEvent(request); diff --git a/frontend/src/shared/api/documents.test.ts b/frontend/src/shared/api/documents.test.ts deleted file mode 100644 index 1101218..0000000 --- a/frontend/src/shared/api/documents.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createDocument, - deleteDocument, - listPolicyDocuments, - updateDocument, -} from '@/shared/api/documents'; -import { apiRequest } from '@/shared/api/httpClient'; -import type { DocumentMutationDto } from '@/shared/types/documents'; - -vi.mock('@/shared/api/httpClient', () => ({ - apiRequest: vi.fn(), -})); - -const apiRequestMock = vi.mocked(apiRequest); - -const documentRequest: DocumentMutationDto = { - entity_type: 'organization', - entity_reference: 'Safety', - name: 'Safety Policy', - category: 'policy', - uploaded_at: '2026-06-08T10:00:00.000Z', - notes: 'Policy content', -}; - -describe('documents API', () => { - beforeEach(() => { - apiRequestMock.mockReset(); - }); - - it('lists policy documents with the policy category query', () => { - void listPolicyDocuments(); - - expect(apiRequestMock).toHaveBeenCalledWith('/documents?category=policy'); - }); - - it('creates documents with POST body wrapped in data', () => { - void createDocument(documentRequest); - - expect(apiRequestMock).toHaveBeenCalledWith('/documents', { - method: 'POST', - body: { data: documentRequest }, - }); - }); - - it('updates documents with PUT body containing id and data', () => { - void updateDocument('document-1', documentRequest); - - expect(apiRequestMock).toHaveBeenCalledWith('/documents/document-1', { - method: 'PUT', - body: { - id: 'document-1', - data: documentRequest, - }, - }); - }); - - it('deletes documents through the item endpoint', () => { - void deleteDocument('document-1'); - - expect(apiRequestMock).toHaveBeenCalledWith('/documents/document-1', { - method: 'DELETE', - }); - }); -}); diff --git a/frontend/src/shared/api/documents.ts b/frontend/src/shared/api/documents.ts deleted file mode 100644 index bc9efae..0000000 --- a/frontend/src/shared/api/documents.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { apiRequest } from '@/shared/api/httpClient'; -import { ApiListResponse } from '@/shared/types/api'; -import type { DocumentDto, DocumentMutationDto } from '@/shared/types/documents'; - -const DOCUMENTS_PATH = '/documents'; - -function createDocumentsPath(params: URLSearchParams): string { - const query = params.toString(); - return query ? `${DOCUMENTS_PATH}?${query}` : DOCUMENTS_PATH; -} - -export function listPolicyDocuments(): Promise> { - return apiRequest>( - createDocumentsPath(new URLSearchParams({ category: 'policy' })), - ); -} - -export function createDocument( - request: DocumentMutationDto, -): Promise { - return apiRequest(DOCUMENTS_PATH, { - method: 'POST', - body: { data: request }, - }); -} - -export function updateDocument( - id: string, - request: DocumentMutationDto, -): Promise { - return apiRequest(`${DOCUMENTS_PATH}/${id}`, { - method: 'PUT', - body: { - id, - data: request, - }, - }); -} - -export function deleteDocument(id: string): Promise { - return apiRequest(`${DOCUMENTS_PATH}/${id}`, { - method: 'DELETE', - }); -} diff --git a/frontend/src/shared/api/frame.ts b/frontend/src/shared/api/frame.ts index d23ef7e..f0334a8 100644 --- a/frontend/src/shared/api/frame.ts +++ b/frontend/src/shared/api/frame.ts @@ -21,3 +21,9 @@ export function updateFrameEntry(id: string, request: FrameEntryMutationDto): Pr body: { data: request }, }); } + +export function deleteFrameEntry(id: string): Promise { + return apiRequest(`${FRAME_ENTRIES_PATH}/${id}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/shared/api/httpClient.test.ts b/frontend/src/shared/api/httpClient.test.ts index e68d225..01c5565 100644 --- a/frontend/src/shared/api/httpClient.test.ts +++ b/frontend/src/shared/api/httpClient.test.ts @@ -114,7 +114,7 @@ describe('http client', () => { it('refreshes once and retries the original request after an auth failure', async () => { const requests = stubFetchSequence([ - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), createJsonResponse({ id: 'user-1' }), createJsonResponse({ id: 'frame-1' }), ]); @@ -130,8 +130,8 @@ describe('http client', () => { it('throws auth-expired when refresh credentials are invalid', async () => { stubFetchSequence([ - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), ]); await expect(apiRequest('/frame_entries')).rejects.toEqual(new AuthExpiredError()); @@ -139,9 +139,9 @@ describe('http client', () => { it('does not loop when the retried request still fails with auth status', async () => { const requests = stubFetchSequence([ - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), createJsonResponse({ id: 'user-1' }), - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), ]); await expect(apiRequest('/frame_entries')).rejects.toEqual(new AuthExpiredError()); @@ -151,7 +151,7 @@ describe('http client', () => { it('does not recursively refresh refresh requests', async () => { const requests = stubFetchSequence([ - createJsonResponse({ message: 'Forbidden' }, { status: 403 }), + createJsonResponse({ message: 'Unauthorized' }, { status: 401 }), ]); await expect( diff --git a/frontend/src/shared/api/httpClient.ts b/frontend/src/shared/api/httpClient.ts index 68da458..af743c1 100644 --- a/frontend/src/shared/api/httpClient.ts +++ b/frontend/src/shared/api/httpClient.ts @@ -142,7 +142,10 @@ export async function apiRequest( let response = await sendRequest(path, options); let retriedAfterRefresh = false; - if ((response.status === 401 || response.status === 403) && canRefresh(path, options)) { + // Only 401 (unauthenticated) drives the refresh/expiry path. A 403 means the + // user is authenticated but lacks permission for this request; it must surface + // as a normal ApiError (handled by the UI), never a session refresh or logout. + if (response.status === 401 && canRefresh(path, options)) { await refreshSession(); retriedAfterRefresh = true; response = await sendRequest(path, { @@ -151,7 +154,7 @@ export async function apiRequest( }); } - if ((response.status === 401 || response.status === 403) && (options.skipAuthRefresh || retriedAfterRefresh)) { + if (response.status === 401 && (options.skipAuthRefresh || retriedAfterRefresh)) { throw new AuthExpiredError(); } diff --git a/frontend/src/shared/api/policyAcknowledgments.test.ts b/frontend/src/shared/api/policyAcknowledgments.test.ts new file mode 100644 index 0000000..caf4e64 --- /dev/null +++ b/frontend/src/shared/api/policyAcknowledgments.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + acknowledgePolicyDocument, + listMyPolicyAcknowledgments, +} from '@/shared/api/policyAcknowledgments'; +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments'; + +vi.mock('@/shared/api/httpClient', () => ({ + apiRequest: vi.fn(), +})); + +const apiRequestMock = vi.mocked(apiRequest); + +describe('policyAcknowledgments API', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + describe('listMyPolicyAcknowledgments', () => { + it('fetches all acknowledgments when no document ID is provided', async () => { + const response: ApiListResponse = { + rows: [ + { + id: 'ack-1', + policyDocumentId: 'doc-1', + version: 1, + userId: 'user-1', + acknowledgedAt: '2024-01-01T00:00:00Z', + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ], + count: 1, + }; + apiRequestMock.mockResolvedValueOnce(response); + + await expect(listMyPolicyAcknowledgments()).resolves.toEqual(response); + + expect(apiRequestMock).toHaveBeenCalledWith('/policy_acknowledgments'); + }); + + it('fetches acknowledgments for a specific document when ID is provided', async () => { + const response: ApiListResponse = { + rows: [], + count: 0, + }; + apiRequestMock.mockResolvedValueOnce(response); + + await expect(listMyPolicyAcknowledgments('doc-123')).resolves.toEqual( + response, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + '/policy_acknowledgments?policyDocumentId=doc-123', + ); + }); + }); + + describe('acknowledgePolicyDocument', () => { + it('acknowledges a policy document', async () => { + const acknowledgment: PolicyAcknowledgmentDto = { + id: 'ack-new', + policyDocumentId: 'doc-1', + version: 1, + userId: 'user-1', + acknowledgedAt: '2024-01-15T10:00:00Z', + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }; + apiRequestMock.mockResolvedValueOnce(acknowledgment); + + await expect( + acknowledgePolicyDocument({ policyDocumentId: 'doc-1' }), + ).resolves.toEqual(acknowledgment); + + expect(apiRequestMock).toHaveBeenCalledWith('/policy_acknowledgments', { + method: 'POST', + body: { data: { policyDocumentId: 'doc-1' } }, + }); + }); + }); +}); diff --git a/frontend/src/shared/api/policyAcknowledgments.ts b/frontend/src/shared/api/policyAcknowledgments.ts new file mode 100644 index 0000000..bf3c4d5 --- /dev/null +++ b/frontend/src/shared/api/policyAcknowledgments.ts @@ -0,0 +1,27 @@ +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { + PolicyAcknowledgeDto, + PolicyAcknowledgmentDto, +} from '@/shared/types/policyDocuments'; + +const POLICY_ACKNOWLEDGMENTS_PATH = '/policy_acknowledgments'; + +/** The current user's own acknowledgments (optionally for one document). */ +export function listMyPolicyAcknowledgments( + policyDocumentId?: string, +): Promise> { + const path = policyDocumentId + ? `${POLICY_ACKNOWLEDGMENTS_PATH}?${new URLSearchParams({ policyDocumentId }).toString()}` + : POLICY_ACKNOWLEDGMENTS_PATH; + return apiRequest>(path); +} + +export function acknowledgePolicyDocument( + request: PolicyAcknowledgeDto, +): Promise { + return apiRequest(POLICY_ACKNOWLEDGMENTS_PATH, { + method: 'POST', + body: { data: request }, + }); +} diff --git a/frontend/src/shared/api/policyDocuments.test.ts b/frontend/src/shared/api/policyDocuments.test.ts new file mode 100644 index 0000000..7384efe --- /dev/null +++ b/frontend/src/shared/api/policyDocuments.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createPolicyDocument, + deletePolicyDocument, + listPolicyDocuments, + updatePolicyDocument, +} from '@/shared/api/policyDocuments'; +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { PolicyDocumentDto } from '@/shared/types/policyDocuments'; + +vi.mock('@/shared/api/httpClient', () => ({ + apiRequest: vi.fn(), +})); + +const apiRequestMock = vi.mocked(apiRequest); + +describe('policyDocuments API', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + describe('listPolicyDocuments', () => { + it('fetches policy documents by category', async () => { + const response: ApiListResponse = { + rows: [ + { + id: 'doc-1', + title: 'Fire Drill Procedures', + body: null, + category: 'safety_protocol', + tag: 'fire', + author: 'Dr. Sarah Williams', + steps: ['Step 1', 'Step 2'], + autism_considerations: ['Consideration 1'], + version: 1, + active: true, + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ], + count: 1, + }; + apiRequestMock.mockResolvedValueOnce(response); + + await expect(listPolicyDocuments('safety_protocol')).resolves.toEqual( + response, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + '/policy_documents?category=safety_protocol', + ); + }); + }); + + describe('createPolicyDocument', () => { + it('creates a new policy document', async () => { + apiRequestMock.mockResolvedValueOnce(true); + + await expect( + createPolicyDocument({ + title: 'New Handbook Policy', + category: 'handbook_policy', + body: 'Policy content here', + tag: 'Operations', + }), + ).resolves.toBe(true); + + expect(apiRequestMock).toHaveBeenCalledWith('/policy_documents', { + method: 'POST', + body: { + data: { + title: 'New Handbook Policy', + category: 'handbook_policy', + body: 'Policy content here', + tag: 'Operations', + }, + }, + }); + }); + }); + + describe('updatePolicyDocument', () => { + it('updates an existing policy document', async () => { + apiRequestMock.mockResolvedValueOnce(true); + + await expect( + updatePolicyDocument('doc-1', { + title: 'Updated Safety Protocol', + category: 'safety_protocol', + steps: ['Updated step 1', 'Updated step 2'], + active: true, + }), + ).resolves.toBe(true); + + expect(apiRequestMock).toHaveBeenCalledWith('/policy_documents/doc-1', { + method: 'PUT', + body: { + id: 'doc-1', + data: { + title: 'Updated Safety Protocol', + category: 'safety_protocol', + steps: ['Updated step 1', 'Updated step 2'], + active: true, + }, + }, + }); + }); + }); + + describe('deletePolicyDocument', () => { + it('deletes a policy document', async () => { + apiRequestMock.mockResolvedValueOnce(true); + + await expect(deletePolicyDocument('doc-1')).resolves.toBe(true); + + expect(apiRequestMock).toHaveBeenCalledWith('/policy_documents/doc-1', { + method: 'DELETE', + }); + }); + }); +}); diff --git a/frontend/src/shared/api/policyDocuments.ts b/frontend/src/shared/api/policyDocuments.ts new file mode 100644 index 0000000..94021f7 --- /dev/null +++ b/frontend/src/shared/api/policyDocuments.ts @@ -0,0 +1,43 @@ +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { + PolicyDocumentCategory, + PolicyDocumentDto, + PolicyDocumentMutationDto, +} from '@/shared/types/policyDocuments'; + +const POLICY_DOCUMENTS_PATH = '/policy_documents'; + +export function listPolicyDocuments( + category: PolicyDocumentCategory, +): Promise> { + const query = new URLSearchParams({ category }).toString(); + return apiRequest>( + `${POLICY_DOCUMENTS_PATH}?${query}`, + ); +} + +export function createPolicyDocument( + request: PolicyDocumentMutationDto, +): Promise { + return apiRequest(POLICY_DOCUMENTS_PATH, { + method: 'POST', + body: { data: request }, + }); +} + +export function updatePolicyDocument( + id: string, + request: PolicyDocumentMutationDto, +): Promise { + return apiRequest(`${POLICY_DOCUMENTS_PATH}/${id}`, { + method: 'PUT', + body: { id, data: request }, + }); +} + +export function deletePolicyDocument(id: string): Promise { + return apiRequest(`${POLICY_DOCUMENTS_PATH}/${id}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/shared/auth/permissions.ts b/frontend/src/shared/auth/permissions.ts new file mode 100644 index 0000000..5520948 --- /dev/null +++ b/frontend/src/shared/auth/permissions.ts @@ -0,0 +1,41 @@ +/** + * Frontend permission vocabulary (Workstream 3 §3.6 / §3.2). Mirrors the backend + * permission names (`${VERB}_${ENTITY}`, see `backend/src/db/seeders/ + * 20200430130760-user-roles.ts`). This is UI config only — the backend remains + * the sole authority; these names are used to gate UI affordances so the UI + * matches what the backend will allow. Do not import backend code here. + */ + +export const PERMISSION_ENTITIES = [ + 'users', 'roles', 'permissions', 'organizations', 'campuses', + 'academic_years', 'grades', 'subjects', 'staff', + 'classes', 'class_enrollments', 'class_subjects', 'timetables', + 'timetable_periods', 'attendance_sessions', 'attendance_records', + 'assessments', 'assessment_results', 'messages', + 'message_recipients', 'policy_documents', +] as const; + +export const PERMISSION_VERBS = ['CREATE', 'READ', 'UPDATE', 'DELETE'] as const; + +export const SPECIAL_PERMISSIONS = ['READ_API_DOCS', 'CREATE_SEARCH'] as const; + +/** + * Product module/page permissions (§3.2). Read = page visibility; the three + * actions are extra rights. Mirrors the backend module-permission catalog. + */ +export const MODULE_PERMISSIONS = [ + 'READ_DASHBOARD', 'READ_FRAME', 'READ_CLASSROOM', 'READ_TIMER', 'READ_QBS', + 'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM', + 'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY', + 'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD', + 'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', +] as const; + +export type PermissionEntity = (typeof PERMISSION_ENTITIES)[number]; +export type PermissionVerb = (typeof PERMISSION_VERBS)[number]; +export type CrudPermission = `${PermissionVerb}_${Uppercase}`; +export type SpecialPermission = (typeof SPECIAL_PERMISSIONS)[number]; +export type ModulePermission = (typeof MODULE_PERMISSIONS)[number]; + +/** Any permission name the backend may grant. */ +export type PermissionName = CrudPermission | SpecialPermission | ModulePermission; diff --git a/frontend/src/shared/constants/appData.ts b/frontend/src/shared/constants/appData.ts index 65f8c0d..ee03584 100644 --- a/frontend/src/shared/constants/appData.ts +++ b/frontend/src/shared/constants/appData.ts @@ -1,25 +1,54 @@ -import type { Module } from '@/shared/types/app'; +import type { Module, UserRole } from '@/shared/types/app'; import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; +// Role groups for module visibility (frontend UX gating; the backend remains the +// authority). Admin tier (system + organization scope) sees everything. +const ADMIN: readonly UserRole[] = [ + 'super_admin', + 'system_admin', + 'owner', + 'superintendent', +]; +const ALL_STAFF: readonly UserRole[] = [ + ...ADMIN, + 'director', + 'office_manager', + 'teacher', + 'support_staff', +]; +// Campus modules office_manager does not get (classroom/instructional tools). +const STAFF_NO_OFFICE: readonly UserRole[] = [ + ...ADMIN, + 'director', + 'teacher', + 'support_staff', +]; +// Parent communication: teaching staff + admin (no support/office). +const PARENT_COMM: readonly UserRole[] = [...ADMIN, 'director', 'teacher']; +// Director-only surfaces (dashboard, walkthrough). +const DIRECTOR_ONLY: readonly UserRole[] = [...ADMIN, 'director']; +// External-facing pages: all staff plus students and guardians. +const EXTERNAL: readonly UserRole[] = [...ALL_STAFF, 'student', 'guardian']; + export const MODULES: Module[] = [ - { id: 'dashboard', name: 'Home Dashboard', icon: 'home', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-violet-500', routePath: APP_ROUTE_PATHS.dashboard }, - { id: 'frame', name: 'F.R.A.M.E. Weekly', icon: 'frame', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-amber-500', routePath: APP_ROUTE_PATHS.frame }, - { id: 'classroom', name: 'Classroom Support', icon: 'book', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-emerald-500', routePath: APP_ROUTE_PATHS.classroom }, - { id: 'timer', name: 'Classroom Timer', icon: 'timer', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-cyan-500', routePath: APP_ROUTE_PATHS.timer }, - { id: 'qbs', name: 'De-escalation Strategies', icon: 'shield', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-blue-500', routePath: APP_ROUTE_PATHS.qbs }, - { id: 'ei', name: 'Emotional Intelligence', icon: 'heart', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-pink-500', routePath: APP_ROUTE_PATHS.ei }, - { id: 'zones', name: 'Regulate your Zone', icon: 'layers', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-teal-500', routePath: APP_ROUTE_PATHS.zones }, - { id: 'signs', name: 'Sign Language', icon: 'hand', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-indigo-500', routePath: APP_ROUTE_PATHS.signs }, - { id: 'attendance', name: 'Attendance', icon: 'clock', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-orange-500', routePath: APP_ROUTE_PATHS.attendance }, - { id: 'parent-comm', name: 'Parent Communication', icon: 'message', roles: ['teacher', 'director', 'superintendent'], color: 'bg-cyan-500', routePath: APP_ROUTE_PATHS.parentComm }, - { id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-rose-500', routePath: APP_ROUTE_PATHS.internalComm }, - { id: 'safety', name: 'Safety Protocols', icon: 'alert', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-red-500', routePath: APP_ROUTE_PATHS.safety }, - { id: 'handbook', name: 'Handbook & Policies', icon: 'file', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-slate-600', routePath: APP_ROUTE_PATHS.handbook }, - { id: 'community', name: 'Community & Partnerships', icon: 'globe', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community }, - { id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational }, - { id: 'esa', name: 'ESA Funding Info', icon: 'wallet', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa }, - { id: 'walkthrough', name: 'Walk-Through Check-In', icon: 'clipboard', roles: ['director', 'superintendent'], color: 'bg-indigo-600', routePath: APP_ROUTE_PATHS.walkthrough }, - { id: 'director', name: 'Director Dashboard', icon: 'chart', roles: ['director', 'superintendent'], color: 'bg-purple-600', routePath: APP_ROUTE_PATHS.director }, + { id: 'dashboard', name: 'Home Dashboard', icon: 'home', roles: ALL_STAFF, color: 'bg-violet-500', routePath: APP_ROUTE_PATHS.dashboard }, + { id: 'frame', name: 'F.R.A.M.E. Weekly', icon: 'frame', roles: ALL_STAFF, color: 'bg-amber-500', routePath: APP_ROUTE_PATHS.frame }, + { id: 'classroom', name: 'Classroom Support', icon: 'book', roles: STAFF_NO_OFFICE, color: 'bg-emerald-500', routePath: APP_ROUTE_PATHS.classroom }, + { id: 'timer', name: 'Classroom Timer', icon: 'timer', roles: STAFF_NO_OFFICE, color: 'bg-cyan-500', routePath: APP_ROUTE_PATHS.timer }, + { id: 'qbs', name: 'Behavior Management', icon: 'shield', roles: STAFF_NO_OFFICE, color: 'bg-blue-500', routePath: APP_ROUTE_PATHS.qbs }, + { id: 'ei', name: 'Emotional Intelligence', icon: 'heart', roles: ALL_STAFF, color: 'bg-pink-500', routePath: APP_ROUTE_PATHS.ei }, + { id: 'zones', name: 'Regulate your Zone', icon: 'layers', roles: STAFF_NO_OFFICE, color: 'bg-teal-500', routePath: APP_ROUTE_PATHS.zones }, + { id: 'signs', name: 'Sign Language', icon: 'hand', roles: STAFF_NO_OFFICE, color: 'bg-indigo-500', routePath: APP_ROUTE_PATHS.signs }, + { id: 'attendance', name: 'Attendance', icon: 'clock', roles: ALL_STAFF, color: 'bg-orange-500', routePath: APP_ROUTE_PATHS.attendance }, + { id: 'parent-comm', name: 'Parent Communication', icon: 'message', roles: PARENT_COMM, color: 'bg-cyan-500', routePath: APP_ROUTE_PATHS.parentComm }, + { id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', roles: ALL_STAFF, color: 'bg-rose-500', routePath: APP_ROUTE_PATHS.internalComm }, + { id: 'safety', name: 'Safety Protocols', icon: 'alert', roles: ALL_STAFF, color: 'bg-red-500', routePath: APP_ROUTE_PATHS.safety }, + { id: 'handbook', name: 'Handbook & Policies', icon: 'file', roles: ALL_STAFF, color: 'bg-slate-600', routePath: APP_ROUTE_PATHS.handbook }, + { id: 'community', name: 'Community & Partnerships', icon: 'globe', roles: EXTERNAL, color: 'bg-green-600', routePath: APP_ROUTE_PATHS.community }, + { id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', roles: EXTERNAL, color: 'bg-sky-600', routePath: APP_ROUTE_PATHS.vocational }, + { id: 'esa', name: 'ESA Funding Info', icon: 'wallet', roles: EXTERNAL, color: 'bg-emerald-600', routePath: APP_ROUTE_PATHS.esa }, + { id: 'walkthrough', name: 'Walk-Through Check-In', icon: 'clipboard', roles: DIRECTOR_ONLY, color: 'bg-indigo-600', routePath: APP_ROUTE_PATHS.walkthrough }, + { id: 'director', name: 'Director Dashboard', icon: 'chart', roles: DIRECTOR_ONLY, color: 'bg-purple-600', routePath: APP_ROUTE_PATHS.director }, ]; export const HERO_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658813159_517b0df6.jpg'; diff --git a/frontend/src/shared/constants/classroomTimer.ts b/frontend/src/shared/constants/classroomTimer.ts index e335a58..c110d86 100644 --- a/frontend/src/shared/constants/classroomTimer.ts +++ b/frontend/src/shared/constants/classroomTimer.ts @@ -5,3 +5,19 @@ export const TIMER_FINISH_REPEAT_DELAY_MS = 3000; export const TIMER_PREVIEW_DURATION_MS = 2000; export const FULLSCREEN_PARTICLE_COUNT = 20; export const TIMER_CARD_PARTICLE_COUNT = 12; + +import type { TimerSoundGroupId } from '@/shared/types/classroomTimer'; + +// Labels + fallback icons for the sound picker, grouped by origin for clear +// structure (built-in synth, AI-generated recipes, uploaded/linked files). +export const TIMER_SOUND_GROUP_LABELS: Record = { + builtin: 'Built-in', + generated: 'Generated', + uploaded: 'Uploaded', +}; + +export const TIMER_GENERATED_SOUND_ICON = '✨'; +export const TIMER_UPLOADED_SOUND_ICON = '🎧'; + +// Toast shown when a generated sound is saved to the library. +export const TIMER_SOUND_GENERATED_MESSAGE = 'New sound generated and added to your library.'; diff --git a/frontend/src/shared/constants/communications.ts b/frontend/src/shared/constants/communications.ts index bff2ab6..70ace09 100644 --- a/frontend/src/shared/constants/communications.ts +++ b/frontend/src/shared/constants/communications.ts @@ -39,8 +39,8 @@ export const COMMUNICATION_EVENT_TYPES: readonly CommunicationEventType[] = [ export const COMMUNICATION_EVENT_DEFAULT_ROLES: readonly UserRole[] = [ 'teacher', - 'para', - 'office', + 'support_staff', + 'office_manager', 'director', ]; diff --git a/frontend/src/shared/constants/dashboard.ts b/frontend/src/shared/constants/dashboard.ts index 976e8bd..88fe9c5 100644 --- a/frontend/src/shared/constants/dashboard.ts +++ b/frontend/src/shared/constants/dashboard.ts @@ -79,7 +79,7 @@ export const DASHBOARD_QUICK_ACTIONS: readonly DashboardQuickAction[] = [ module: 'classroom', color: 'from-emerald-500 to-emerald-600', shadow: 'shadow-emerald-500/30', - hiddenForRoles: ['office'], + hiddenForRoles: ['office_manager'], }, { label: 'Class Timer', diff --git a/frontend/src/shared/constants/footerNavigation.ts b/frontend/src/shared/constants/footerNavigation.ts index a1e0b93..d334d1b 100644 --- a/frontend/src/shared/constants/footerNavigation.ts +++ b/frontend/src/shared/constants/footerNavigation.ts @@ -8,7 +8,7 @@ export interface FooterModuleLink { export const CORE_FOOTER_LINKS: readonly FooterModuleLink[] = [ { label: 'Classroom Support', moduleId: 'classroom' }, { label: 'Classroom Timer', moduleId: 'timer' }, - { label: 'De-escalation Strategies', moduleId: 'qbs' }, + { label: 'Behavior Management', moduleId: 'qbs' }, { label: 'Regulate your Zone', moduleId: 'zones' }, { label: 'Sign Language', moduleId: 'signs' }, ]; diff --git a/frontend/src/shared/constants/guestPreviewRoles.ts b/frontend/src/shared/constants/guestPreviewRoles.ts deleted file mode 100644 index 67ce06a..0000000 --- a/frontend/src/shared/constants/guestPreviewRoles.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { UserRole } from '@/shared/types/app'; - -export const GUEST_PREVIEW_ROLE_OPTIONS: readonly { - readonly value: UserRole; - readonly label: string; - readonly color: string; -}[] = [ - { value: 'teacher', label: 'Teacher', color: 'text-emerald-400 bg-emerald-500/15 border-emerald-500/30' }, - { value: 'para', label: 'Support Staff', color: 'text-blue-400 bg-blue-500/15 border-blue-500/30' }, - { value: 'office', label: 'Office Manager', color: 'text-amber-400 bg-amber-500/15 border-amber-500/30' }, - { value: 'director', label: 'Director', color: 'text-purple-400 bg-purple-500/15 border-purple-500/30' }, - { value: 'superintendent', label: 'Superintendent', color: 'text-rose-400 bg-rose-500/15 border-rose-500/30' }, -]; - -export const DEFAULT_GUEST_PREVIEW_ROLE: UserRole = 'teacher'; -export const GUEST_PREVIEW_USER_NAME = 'Guest'; diff --git a/frontend/src/shared/constants/moduleRoutes.test.ts b/frontend/src/shared/constants/moduleRoutes.test.ts index b4ca598..e0d7cc9 100644 --- a/frontend/src/shared/constants/moduleRoutes.test.ts +++ b/frontend/src/shared/constants/moduleRoutes.test.ts @@ -4,6 +4,7 @@ import { canUserRoleAccessModuleRoute, DEFAULT_MODULE_ID, DEFAULT_MODULE_ROUTE_PATH, + getDefaultRoutePathForRole, getModuleByRoutePath, getModuleIdByRoutePath, getModuleRoutePath, @@ -36,5 +37,20 @@ describe('module route metadata', () => { expect(canUserRoleAccessModuleRoute('/director-dashboard', 'teacher')).toBe(false); expect(canUserRoleAccessModuleRoute('/director-dashboard', 'director')).toBe(true); expect(canUserRoleAccessModuleRoute('/classroom-timer', 'teacher')).toBe(true); + // office_manager keeps the original distinction (no instructional tools). + expect(canUserRoleAccessModuleRoute('/classroom-timer', 'office_manager')).toBe(false); + // external roles only reach the external pages. + expect(canUserRoleAccessModuleRoute('/community-partnerships', 'student')).toBe(true); + expect(canUserRoleAccessModuleRoute('/dashboard', 'student')).toBe(false); + }); + + it('lands each role on its first accessible module', () => { + const community = MODULES.find((m) => m.id === 'community')?.routePath; + expect(getDefaultRoutePathForRole('teacher')).toBe(DEFAULT_MODULE_ROUTE_PATH); + expect(getDefaultRoutePathForRole('director')).toBe(DEFAULT_MODULE_ROUTE_PATH); + // student/guardian cannot see the dashboard, so they land on the first + // external page instead. + expect(getDefaultRoutePathForRole('student')).toBe(community); + expect(getDefaultRoutePathForRole('guardian')).toBe(community); }); }); diff --git a/frontend/src/shared/constants/moduleRoutes.ts b/frontend/src/shared/constants/moduleRoutes.ts index ae7617c..e4003f1 100644 --- a/frontend/src/shared/constants/moduleRoutes.ts +++ b/frontend/src/shared/constants/moduleRoutes.ts @@ -43,3 +43,14 @@ export function canUserRoleAccessModuleRoute( return module ? module.roles.includes(userRole) : true; } + +/** + * The landing route for a role: the first module the role can access (so e.g. + * students/guardians land on a page they're allowed to see rather than the + * dashboard, which they cannot access). Falls back to the dashboard. + */ +export function getDefaultRoutePathForRole(userRole: UserRole): string { + const accessible = MODULES.find((module) => module.roles.includes(userRole)); + + return accessible ? accessible.routePath : DEFAULT_MODULE_ROUTE_PATH; +} diff --git a/frontend/src/shared/constants/policies.ts b/frontend/src/shared/constants/policies.ts index 1a207b4..cf8d0a5 100644 --- a/frontend/src/shared/constants/policies.ts +++ b/frontend/src/shared/constants/policies.ts @@ -7,6 +7,18 @@ export const POLICY_DATE_NOT_RECORDED_LABEL = 'Not recorded'; export const POLICY_QUERY_KEYS = { documents: ['policies', 'documents'], + safetyDocuments: ['policies', 'safety-documents'], + acknowledgments: ['policies', 'acknowledgments'], +} as const; + +/** + * The unified `policy_documents` store splits the two pages by `category` and + * carries the handbook's finer sub-category in `tag`. The handbook page maps its + * existing {@link POLICY_CATEGORIES} sub-categories onto `policy_documents.tag`. + */ +export const POLICY_DOCUMENT_PAGE_CATEGORY = { + safetyProtocols: 'safety_protocol', + handbookPolicies: 'handbook_policy', } as const; export const POLICY_CATEGORIES: readonly PolicyCategory[] = [ diff --git a/frontend/src/shared/constants/roles.ts b/frontend/src/shared/constants/roles.ts index fedbe2a..893aed5 100644 --- a/frontend/src/shared/constants/roles.ts +++ b/frontend/src/shared/constants/roles.ts @@ -1,3 +1,23 @@ import { UserRole } from '@/shared/types/app'; +/** Fallback UI role when the backend role name is missing/unrecognized. */ export const DEFAULT_PRODUCT_ROLE: UserRole = 'teacher'; + +/** All role names the backend may send as `app_role.name`. */ +export const USER_ROLE_VALUES: readonly UserRole[] = [ + 'super_admin', + 'system_admin', + 'owner', + 'superintendent', + 'director', + 'office_manager', + 'teacher', + 'support_staff', + 'student', + 'guardian', + 'guest', +]; + +export function isUserRole(value: string | null | undefined): value is UserRole { + return value != null && (USER_ROLE_VALUES as readonly string[]).includes(value); +} diff --git a/frontend/src/shared/constants/safetyProtocols.ts b/frontend/src/shared/constants/safetyProtocols.ts new file mode 100644 index 0000000..463089d --- /dev/null +++ b/frontend/src/shared/constants/safetyProtocols.ts @@ -0,0 +1,44 @@ +import { AlertTriangle, Heart, Shield } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +/** + * Static per-`tag` card style (icon + gradient) for safety protocols. The tag is + * DB data on each `policy_documents` row; this map is frontend config keyed by + * the lowercased tag, with a default fallback. + */ +export interface SafetyTagStyle { + readonly icon: LucideIcon; + readonly color: string; +} + +export const SAFETY_TAG_STYLES: Record = { + fire: { icon: AlertTriangle, color: 'from-orange-400 to-red-500' }, + lockdown: { icon: Shield, color: 'from-blue-400 to-blue-600' }, + shield: { icon: Shield, color: 'from-blue-400 to-blue-600' }, + medical: { icon: Heart, color: 'from-emerald-400 to-emerald-600' }, + heart: { icon: Heart, color: 'from-emerald-400 to-emerald-600' }, +}; + +export const DEFAULT_SAFETY_STYLE: SafetyTagStyle = { + icon: AlertTriangle, + color: 'from-violet-400 to-violet-600', +}; + +export function safetyStyleForTag(tag: string | null): SafetyTagStyle { + return (tag && SAFETY_TAG_STYLES[tag.toLowerCase()]) || DEFAULT_SAFETY_STYLE; +} + +/** Selectable category tags offered by the authoring form (drive the card icon). */ +export interface SafetyTagOption { + readonly value: string; + readonly label: string; +} + +export const SAFETY_PROTOCOL_TAG_OPTIONS: readonly SafetyTagOption[] = [ + { value: 'fire', label: 'Fire / Evacuation' }, + { value: 'lockdown', label: 'Lockdown' }, + { value: 'medical', label: 'Medical' }, + { value: 'general', label: 'General' }, +]; + +export const SAFETY_PROTOCOL_DEFAULT_TAG = SAFETY_PROTOCOL_TAG_OPTIONS[0].value; diff --git a/frontend/src/shared/constants/topBar.ts b/frontend/src/shared/constants/topBar.ts index 12a2c55..f33e48f 100644 --- a/frontend/src/shared/constants/topBar.ts +++ b/frontend/src/shared/constants/topBar.ts @@ -1,11 +1,17 @@ import type { UserRole } from '@/shared/types/app'; export const TOP_BAR_ROLE_BADGE_CLASSES: Record = { - teacher: { color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20' }, - para: { color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/20' }, - office: { color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/20' }, - director: { color: 'text-purple-400', bg: 'bg-purple-500/15 border-purple-500/20' }, + super_admin: { color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' }, + system_admin: { color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' }, + owner: { color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/15 border-fuchsia-500/20' }, superintendent: { color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' }, + director: { color: 'text-purple-400', bg: 'bg-purple-500/15 border-purple-500/20' }, + office_manager: { color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/20' }, + teacher: { color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20' }, + support_staff: { color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/20' }, + student: { color: 'text-sky-400', bg: 'bg-sky-500/15 border-sky-500/20' }, + guardian: { color: 'text-teal-400', bg: 'bg-teal-500/15 border-teal-500/20' }, + guest: { color: 'text-slate-400', bg: 'bg-slate-500/15 border-slate-500/20' }, }; export const TOP_BAR_PROFILE_MENU_ITEMS = [ diff --git a/frontend/src/shared/errors/errorMessages.ts b/frontend/src/shared/errors/errorMessages.ts index a607db6..d91e97c 100644 --- a/frontend/src/shared/errors/errorMessages.ts +++ b/frontend/src/shared/errors/errorMessages.ts @@ -2,6 +2,13 @@ import { ApiError, AuthExpiredError } from '@/shared/api/httpClient'; export const DEFAULT_ERROR_MESSAGE = 'Request failed'; export const AUTH_EXPIRED_ERROR_MESSAGE = 'Please sign in again.'; +export const FORBIDDEN_ERROR_MESSAGE = + "You don't have permission to do that."; + +/** True for a backend 403 (authenticated but not permitted). */ +export function isForbiddenError(error: unknown): boolean { + return error instanceof ApiError && error.status === 403; +} export function getErrorMessage(error: unknown, fallbackMessage = DEFAULT_ERROR_MESSAGE): string { if (error instanceof AuthExpiredError) { diff --git a/frontend/src/shared/types/app.ts b/frontend/src/shared/types/app.ts index 78c4eaf..51baad5 100644 --- a/frontend/src/shared/types/app.ts +++ b/frontend/src/shared/types/app.ts @@ -1,4 +1,15 @@ -export type UserRole = 'teacher' | 'para' | 'office' | 'director' | 'superintendent'; +export type UserRole = + | 'super_admin' + | 'system_admin' + | 'owner' + | 'superintendent' + | 'director' + | 'office_manager' + | 'teacher' + | 'support_staff' + | 'student' + | 'guardian' + | 'guest'; export type CampusId = string; diff --git a/frontend/src/shared/types/audioFiles.ts b/frontend/src/shared/types/audioFiles.ts new file mode 100644 index 0000000..ac8c279 --- /dev/null +++ b/frontend/src/shared/types/audioFiles.ts @@ -0,0 +1,47 @@ +/** + * Audio library (Workstream 13). One `audio_files` store backs a flexible sound + * library: each row is a `file` (uploaded binary), a `url` (external link) or a + * `recipe` (client-synthesized sound). `file`/`url` populate `url`; `recipe` + * populates `recipe` and is played purely via the Web Audio API. + */ +export type AudioFileKind = 'file' | 'url' | 'recipe'; + +export type SoundRecipeWaveform = 'sine' | 'triangle' | 'square' | 'sawtooth'; + +export interface SoundRecipeNote { + readonly freq: number; + readonly startAt: number; + readonly duration: number; + readonly gain: number; + readonly attack?: number; + readonly rampFreqTo?: number; +} + +export interface SoundRecipeVoice { + readonly waveform: SoundRecipeWaveform; + readonly notes: readonly SoundRecipeNote[]; +} + +export interface SoundRecipe { + readonly voices: readonly SoundRecipeVoice[]; +} + +export interface AudioFileDto { + readonly id: string; + readonly title: string; + readonly kind: AudioFileKind; + readonly url: string | null; + readonly recipe: SoundRecipe | null; + readonly is_default: boolean; + readonly organizationId: string | null; + readonly campusId: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface AudioFileMutationDto { + readonly kind: AudioFileKind; + readonly title: string; + readonly url?: string; + readonly recipe?: SoundRecipe; +} diff --git a/frontend/src/shared/types/auth.ts b/frontend/src/shared/types/auth.ts index 9fb1ff3..2dbb408 100644 --- a/frontend/src/shared/types/auth.ts +++ b/frontend/src/shared/types/auth.ts @@ -3,6 +3,7 @@ import { UserRole } from '@/shared/types/app'; export interface BackendRole { readonly id?: string; readonly name?: string; + readonly scope?: string | null; readonly globalAccess?: boolean; } @@ -31,6 +32,8 @@ export interface BackendStaffProfile { export interface CurrentUser { readonly id: string; readonly email: string; + /** Honorific title (e.g. `dr`); rendered before the name. */ + readonly name_prefix?: string | null; readonly firstName?: string | null; readonly lastName?: string | null; readonly app_role?: BackendRole | null; @@ -38,7 +41,6 @@ export interface CurrentUser { readonly organizationId?: string | null; readonly campus?: BackendCampus | null; readonly campusId?: string | null; - readonly productRole: UserRole; readonly staffProfile?: BackendStaffProfile | null; readonly permissions?: readonly string[]; } diff --git a/frontend/src/shared/types/classroomTimer.ts b/frontend/src/shared/types/classroomTimer.ts index 6dd36ed..a310120 100644 --- a/frontend/src/shared/types/classroomTimer.ts +++ b/frontend/src/shared/types/classroomTimer.ts @@ -47,6 +47,36 @@ export type TimerSoundOption = { readonly frequency: number; }; +/** Storage origin of a pickable timer sound. */ +export type TimerSoundKind = 'builtin' | 'recipe' | 'file' | 'url'; + +/** + * A unified sound the picker can select and play, regardless of origin: a + * hardcoded built-in (`builtin`, synthesized by id), a generated `recipe` + * (synthesized from parameters), or an `audio_files` `file`/`url` (played from a + * URL). The `key` is unique across all origins. + */ +export type TimerSound = { + readonly key: string; + readonly name: string; + readonly icon: string; + readonly kind: TimerSoundKind; + readonly soundType: TimerSoundType | null; + readonly recipe: import('@/shared/types/audioFiles').SoundRecipe | null; + readonly url: string | null; + readonly audioFileId: string | null; + readonly canDelete: boolean; +}; + +export type TimerSoundGroupId = 'builtin' | 'generated' | 'uploaded'; + +/** A labeled section of sounds for the picker (clear structure by type). */ +export type TimerSoundGroup = { + readonly id: TimerSoundGroupId; + readonly label: string; + readonly sounds: readonly TimerSound[]; +}; + export type PresetTimerOption = { readonly label: string; readonly seconds: number; diff --git a/frontend/src/shared/types/documents.ts b/frontend/src/shared/types/documents.ts deleted file mode 100644 index 1ea723d..0000000 --- a/frontend/src/shared/types/documents.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type DocumentCategory = 'policy' | 'report' | 'id' | 'medical' | 'consent' | 'invoice' | 'receipt' | 'other'; -export type DocumentEntityType = 'student' | 'staff' | 'class' | 'invoice' | 'organization' | 'campus' | 'other'; - -export interface DocumentDto { - readonly id: string; - readonly entity_type: DocumentEntityType | null; - readonly entity_reference: string | null; - readonly name: string | null; - readonly category: DocumentCategory | null; - readonly uploaded_at: string | null; - readonly notes: string | null; - readonly organizationId: string | null; - readonly campusId: string | null; - readonly createdById: string | null; - readonly updatedById: string | null; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface DocumentMutationDto { - readonly entity_type: DocumentEntityType; - readonly entity_reference: string; - readonly name: string; - readonly category: DocumentCategory; - readonly uploaded_at: string; - readonly notes: string; -} diff --git a/frontend/src/shared/types/policyDocuments.ts b/frontend/src/shared/types/policyDocuments.ts new file mode 100644 index 0000000..ab096fd --- /dev/null +++ b/frontend/src/shared/types/policyDocuments.ts @@ -0,0 +1,53 @@ +/** + * Unified policy/safety document slice (Workstream 11). One backend store backs + * both the Safety Protocols and Handbook & Policies pages: `category` selects the + * page, `tag` carries the finer sub-category (e.g. the handbook's + * Operations/Behavior/Safety/Communication/Legal). Acknowledgment is persisted + * per document version. + */ +export type PolicyDocumentCategory = 'safety_protocol' | 'handbook_policy'; + +export interface PolicyDocumentDto { + readonly id: string; + readonly title: string; + readonly body: string | null; + readonly category: PolicyDocumentCategory; + readonly tag: string | null; + /** Display name of the creating user (server-set). */ + readonly author: string | null; + /** Author-filled structured content (safety protocols). */ + readonly steps: readonly string[] | null; + readonly autism_considerations: readonly string[] | null; + readonly version: number; + readonly active: boolean; + readonly organizationId: string | null; + readonly campusId: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface PolicyDocumentMutationDto { + readonly title: string; + readonly category: PolicyDocumentCategory; + readonly body?: string; + readonly tag?: string; + readonly steps?: readonly string[]; + readonly autism_considerations?: readonly string[]; + readonly active?: boolean; +} + +export interface PolicyAcknowledgmentDto { + readonly id: string; + readonly policyDocumentId: string; + readonly version: number; + readonly userId: string; + readonly acknowledgedAt: string; + readonly organizationId: string | null; + readonly campusId: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface PolicyAcknowledgeDto { + readonly policyDocumentId: string; +} diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/frontend/tests/e2e/accessibility.seeded.e2e.ts b/frontend/tests/e2e/accessibility.seeded.e2e.ts new file mode 100644 index 0000000..8982d85 --- /dev/null +++ b/frontend/tests/e2e/accessibility.seeded.e2e.ts @@ -0,0 +1,166 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, type Page, test } from '@playwright/test'; + +const TEST_USER = { + email: 'admin@flatlogic.com', + password: 'flatlogicAdmin123!', +}; + +async function authenticateViaPage(page: Page): Promise { + await page.goto('/'); + await page.getByPlaceholder('you@school.edu').fill(TEST_USER.email); + await page.getByPlaceholder('Enter your password').fill(TEST_USER.password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL('/', { timeout: 10000 }); +} + +async function checkAccessibility(page: Page, pageName: string): Promise { + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .analyze(); + + const violations = results.violations.map((v) => ({ + id: v.id, + impact: v.impact, + description: v.description, + nodes: v.nodes.length, + })); + + expect( + violations, + `Accessibility violations on ${pageName}:\n${JSON.stringify(violations, null, 2)}`, + ).toEqual([]); +} + +test.describe('accessibility compliance', () => { + test('login page has no critical accessibility violations', async ({ page }) => { + await page.goto('/'); + await checkAccessibility(page, 'Login Page'); + }); + + test('dashboard has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Dashboard'); + }); + + test('classroom timer page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/classroom-timer'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Classroom Timer'); + }); + + test('classroom support page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/classroom-support'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Classroom Support'); + }); + + test('sign language page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/sign-language'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Sign Language'); + }); + + test('zones of regulation page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/zones-of-regulation'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Zones of Regulation'); + }); + + test('frame weekly page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/frame'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'F.R.A.M.E. Weekly'); + }); + + test('behavior management page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/qbs-safety'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Behavior Management'); + }); + + test('emotional intelligence page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/emotional-intelligence'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Emotional Intelligence'); + }); + + test('attendance page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/attendance'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Attendance'); + }); + + test('parent communication page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/parent-communication'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Parent Communication'); + }); + + test('internal alerts page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/internal-alerts'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Internal Alerts'); + }); + + test('safety protocols page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/safety-protocols'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Safety Protocols'); + }); + + test('handbook policies page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/handbook-policies'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Handbook & Policies'); + }); + + test('community partnerships page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/community-partnerships'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Community & Partnerships'); + }); + + test('vocational opportunities page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/vocational-opportunities'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Vocational Opportunities'); + }); + + test('esa funding page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/esa-funding'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'ESA Funding Info'); + }); + + test('walkthrough page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/walkthrough'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Walk-Through Check-In'); + }); + + test('director dashboard page has no critical accessibility violations', async ({ page }) => { + await authenticateViaPage(page); + await page.goto('/director-dashboard'); + await page.waitForLoadState('networkidle'); + await checkAccessibility(page, 'Director Dashboard'); + }); +}); diff --git a/frontend/tests/e2e/app-shell.e2e.ts b/frontend/tests/e2e/app-shell.e2e.ts deleted file mode 100644 index 0b79d48..0000000 --- a/frontend/tests/e2e/app-shell.e2e.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, type Page, test } from '@playwright/test'; - -async function switchDemoRole(page: Page, roleLabel: string): Promise { - await page.getByRole('button', { name: /View as:/ }).click(); - await page.getByRole('button', { name: roleLabel }).click(); -} - -test.describe('app shell smoke paths', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/dashboard'); - await expect(page.getByText("You're browsing as a Guest")).toBeVisible(); - }); - - test('teacher guest sees staff modules and cannot access director-only modules', async ({ page }) => { - const navigation = page.getByRole('navigation'); - - await expect(page.getByRole('button', { name: /View as: Teacher/ })).toBeVisible(); - await expect(navigation.getByRole('button', { name: 'Classroom Timer' })).toBeVisible(); - await expect(navigation.getByRole('button', { name: 'Director Dashboard' })).toHaveCount(0); - await expect(navigation.getByRole('button', { name: 'Walk-Through Check-In' })).toHaveCount(0); - - await navigation.getByRole('button', { name: 'Classroom Timer' }).click(); - await expect(page).toHaveURL(/\/classroom-timer$/); - await expect(page.getByText('Loading classroom timer content...')).toBeVisible(); - }); - - test('teacher guest restricted executive route redirects to dashboard', async ({ page }) => { - await page.goto('/director-dashboard'); - - await expect(page).toHaveURL(/\/dashboard$/); - await expect(page.getByText("You're browsing as a Guest")).toBeVisible(); - }); - - test('director guest can reach director dashboard and walk-through module', async ({ page }) => { - const navigation = page.getByRole('navigation'); - - await switchDemoRole(page, 'Director'); - - await expect(page.getByRole('button', { name: /View as: Director/ })).toBeVisible(); - await expect(navigation.getByRole('button', { name: 'Director Dashboard' })).toBeVisible(); - await expect(navigation.getByRole('button', { name: 'Walk-Through Check-In' })).toBeVisible(); - - await navigation.getByRole('button', { name: 'Walk-Through Check-In' }).click(); - await expect(page).toHaveURL(/\/walkthrough$/); - await expect(page.getByRole('heading', { name: 'Walk-Through Check-In' })).toBeVisible(); - }); - - test('superintendent guest keeps executive module access after navigation', async ({ page }) => { - const navigation = page.getByRole('navigation'); - - await switchDemoRole(page, 'Superintendent'); - - await expect(page.getByRole('button', { name: /View as: Superintendent/ })).toBeVisible(); - await navigation.getByRole('button', { name: 'Walk-Through Check-In' }).click(); - await expect(page).toHaveURL(/\/walkthrough$/); - await expect(page.getByRole('heading', { name: 'Walk-Through Check-In' })).toBeVisible(); - - await navigation.getByRole('button', { name: 'Home Dashboard' }).click(); - await expect(page).toHaveURL(/\/dashboard$/); - await expect(navigation.getByRole('button', { name: 'Director Dashboard' })).toBeVisible(); - await expect(navigation.getByRole('button', { name: 'Walk-Through Check-In' })).toBeVisible(); - }); -}); diff --git a/frontend/tests/e2e/audio-files.seeded.e2e.ts b/frontend/tests/e2e/audio-files.seeded.e2e.ts new file mode 100644 index 0000000..2a8fdfd --- /dev/null +++ b/frontend/tests/e2e/audio-files.seeded.e2e.ts @@ -0,0 +1,128 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Audio library e2e (Workstream 13). Proves the RBAC + persistence contract: + * director/office_manager/teacher upload (manage), all four campus roles + * read/play, support_staff is read-only, external roles are locked out. + * + * Requires the backend running with the database migrated + seeded. + */ +const USER_PASSWORD = 'flatlogicUser123!'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +const AUDIO = `${BACKEND_API_URL}/audio_files`; + +const TEACHER = 'teacher@flatlogic.com'; +const SUPPORT_STAFF = 'support_staff@flatlogic.com'; +const DIRECTOR = 'director@flatlogic.com'; +const GUARDIAN = 'guardian@flatlogic.com'; + +const DENIED = [400, 401, 403]; + +interface AudioRow { + readonly id: string; + readonly title: string; + readonly kind: string; + readonly url: string | null; + readonly recipe: unknown; +} + +const SAMPLE_RECIPE = { + voices: [ + { waveform: 'sine', notes: [{ freq: 523.25, startAt: 0, duration: 0.6, gain: 0.3 }] }, + ], +}; + +async function logout(page: Page): Promise { + await page.request.post(`${BACKEND_API_URL}/auth/signout`); + await page.context().clearCookies(); +} + +async function login(page: Page, email: string): Promise { + await logout(page); + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(USER_PASSWORD); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +async function findAudio(page: Page, title: string): Promise { + const res = await page.request.get(AUDIO); + expect(res.status()).toBe(200); + const body = (await res.json()) as { rows?: AudioRow[] }; + return (body.rows ?? []).find((row) => row.title === title); +} + +test.describe('Audio library', () => { + test('a teacher uploads an audio file that persists and peers can read', async ({ + page, + }) => { + await login(page, TEACHER); + const title = `Calm Bell ${Date.now()}`; + const res = await page.request.post(AUDIO, { + data: { data: { title, url: '/files/calm-bell.mp3' } }, + }); + expect(res.ok()).toBe(true); + expect(await findAudio(page, title), 'uploader sees it').toBeTruthy(); + + // A same-campus director can read/play it. + await login(page, DIRECTOR); + expect(await findAudio(page, title), 'director on the campus sees it').toBeTruthy(); + }); + + test('a teacher generates a recipe sound that persists with kind=recipe', async ({ + page, + }) => { + await login(page, TEACHER); + const title = `Generated ${Date.now()}`; + const res = await page.request.post(AUDIO, { + data: { data: { kind: 'recipe', title, recipe: SAMPLE_RECIPE } }, + }); + expect(res.ok()).toBe(true); + + const row = await findAudio(page, title); + expect(row, 'creator sees the recipe row').toBeTruthy(); + expect(row?.kind).toBe('recipe'); + expect(row?.url).toBeNull(); + expect(row?.recipe).toBeTruthy(); + }); + + test('the service rejects content that does not match the kind', async ({ page }) => { + await login(page, TEACHER); + + // recipe kind without a recipe payload. + const missingRecipe = await page.request.post(AUDIO, { + data: { data: { kind: 'recipe', title: 'No recipe' } }, + }); + expect(DENIED).toContain(missingRecipe.status()); + + // file kind without a url. + const missingUrl = await page.request.post(AUDIO, { + data: { data: { kind: 'file', title: 'No url' } }, + }); + expect(DENIED).toContain(missingUrl.status()); + }); + + test('support_staff can read but cannot upload', async ({ page }) => { + await login(page, SUPPORT_STAFF); + expect((await page.request.get(AUDIO)).status()).toBe(200); + const create = await page.request.post(AUDIO, { + data: { data: { title: 'Support should not upload', url: '/files/x.mp3' } }, + }); + expect(DENIED).toContain(create.status()); + }); + + test('external roles cannot access the audio library', async ({ page }) => { + await login(page, GUARDIAN); + expect(DENIED).toContain((await page.request.get(AUDIO)).status()); + const create = await page.request.post(AUDIO, { + data: { data: { title: 'Guardian should not upload', url: '/files/x.mp3' } }, + }); + expect(DENIED).toContain(create.status()); + }); +}); diff --git a/frontend/tests/e2e/content-catalog.seeded.e2e.ts b/frontend/tests/e2e/content-catalog.seeded.e2e.ts index 6c95ce3..e0f5fbf 100644 --- a/frontend/tests/e2e/content-catalog.seeded.e2e.ts +++ b/frontend/tests/e2e/content-catalog.seeded.e2e.ts @@ -1,7 +1,20 @@ -import { expect, type APIRequestContext, test } from '@playwright/test'; +import { expect, type APIRequestContext, type Page, test } from '@playwright/test'; const BACKEND_API_URL = process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; +const TEST_USER = { + email: 'admin@flatlogic.com', + password: 'flatlogicAdmin123!', +}; + +async function authenticateViaPage(page: Page): Promise { + await page.goto('/'); + await page.getByPlaceholder('you@school.edu').fill(TEST_USER.email); + await page.getByPlaceholder('Enter your password').fill(TEST_USER.password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL('/', { timeout: 10000 }); +} + interface ClassroomStrategyPayload { readonly title: string; } @@ -122,6 +135,7 @@ test.describe('seeded content catalog integration', () => { })); const firstSound = getFirstPayloadItem(sounds, 'classroom-timer-sounds'); + await authenticateViaPage(page); await page.goto('/classroom-timer'); await expect(page.getByText('Loading classroom timer content...')).toHaveCount(0); @@ -136,6 +150,7 @@ test.describe('seeded content catalog integration', () => { })); const firstStrategy = getFirstPayloadItem(strategies, 'classroom-strategies'); + await authenticateViaPage(page); await page.goto('/classroom-support'); await expect(page.getByText('Loading classroom strategies...')).toHaveCount(0); @@ -150,6 +165,7 @@ test.describe('seeded content catalog integration', () => { })); const firstSign = getFirstPayloadItem(signs, 'sign-language-items'); + await authenticateViaPage(page); await page.goto('/sign-language'); await expect(page.getByText('Loading sign language content and saved progress.')).toHaveCount(0); @@ -164,6 +180,7 @@ test.describe('seeded content catalog integration', () => { })); const firstZone = getFirstPayloadItem(zones, 'regulation-zones'); + await authenticateViaPage(page); await page.goto('/zones-of-regulation'); await expect(page.getByText('Loading regulation zone content.')).toHaveCount(0); diff --git a/frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts b/frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts new file mode 100644 index 0000000..56c2959 --- /dev/null +++ b/frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts @@ -0,0 +1,159 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Policy documents & acknowledgments e2e (Workstream 11). Proves the RBAC and + * persistence contract: director/office_manager manage documents; the four + * campus staff roles read + acknowledge; external roles are locked out; + * acknowledgment is per-version and idempotent. + * + * Requires the backend running with the database migrated + seeded. + */ +const USER_PASSWORD = 'flatlogicUser123!'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +const DOCS = `${BACKEND_API_URL}/policy_documents`; +const ACKS = `${BACKEND_API_URL}/policy_acknowledgments`; + +const DIRECTOR = 'director@flatlogic.com'; +const OFFICE_MANAGER = 'office_manager@flatlogic.com'; +const TEACHER = 'teacher@flatlogic.com'; +const STUDENT = 'student@flatlogic.com'; + +/** Denials surface as 400 (permission middleware) or 401/403. */ +const DENIED = [400, 401, 403]; + +interface DocRow { + readonly id: string; + readonly title: string; + readonly version: number; +} +interface AckRow { + readonly policyDocumentId: string; + readonly version: number; +} + +async function logout(page: Page): Promise { + await page.request.post(`${BACKEND_API_URL}/auth/signout`); + await page.context().clearCookies(); +} + +async function login(page: Page, email: string): Promise { + await logout(page); + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(USER_PASSWORD); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +async function findDoc(page: Page, title: string): Promise { + const res = await page.request.get(DOCS); + expect(res.status()).toBe(200); + const body = (await res.json()) as { rows?: DocRow[] }; + return (body.rows ?? []).find((row) => row.title === title); +} + +test.describe('Policy documents & acknowledgments', () => { + test('director creates a policy document that persists', async ({ page }) => { + await login(page, DIRECTOR); + const title = `Safety Protocol ${Date.now()}`; + const res = await page.request.post(DOCS, { + data: { data: { title, category: 'safety_protocol', body: 'v1' } }, + }); + expect(res.ok()).toBe(true); + + const doc = await findDoc(page, title); + expect(doc, 'created document is listed').toBeTruthy(); + expect(doc!.version).toBe(1); + }); + + test('office_manager may manage; teacher may read but not create', async ({ + page, + }) => { + await login(page, OFFICE_MANAGER); + const title = `Handbook Policy ${Date.now()}`; + const created = await page.request.post(DOCS, { + data: { data: { title, category: 'handbook_policy' } }, + }); + expect(created.ok()).toBe(true); + + // Teacher reads the org's documents but cannot create one. + await login(page, TEACHER); + expect(await findDoc(page, title), 'teacher can read').toBeTruthy(); + const teacherCreate = await page.request.post(DOCS, { + data: { data: { title: 'Teacher should not create', category: 'handbook_policy' } }, + }); + expect(DENIED).toContain(teacherCreate.status()); + }); + + test('a campus staff member acknowledges a document (idempotent per version)', async ({ + page, + }) => { + // Director publishes a document. + await login(page, DIRECTOR); + const title = `Ack Target ${Date.now()}`; + await page.request.post(DOCS, { + data: { data: { title, category: 'safety_protocol', body: 'v1' } }, + }); + const doc = await findDoc(page, title); + expect(doc).toBeTruthy(); + const documentId = doc!.id; + + // Teacher acknowledges it twice — the second is idempotent. + await login(page, TEACHER); + expect((await page.request.post(ACKS, { data: { data: { policyDocumentId: documentId } } })).ok()).toBe(true); + expect((await page.request.post(ACKS, { data: { data: { policyDocumentId: documentId } } })).ok()).toBe(true); + + const acksRes = await page.request.get(`${ACKS}?policyDocumentId=${documentId}`); + expect(acksRes.status()).toBe(200); + const acks = ((await acksRes.json()) as { rows?: AckRow[] }).rows ?? []; + const forDoc = acks.filter((a) => a.policyDocumentId === documentId); + expect(forDoc).toHaveLength(1); + expect(forDoc[0]?.version).toBe(1); + }); + + test('editing a document bumps the version and requires re-acknowledgment', async ({ + page, + }) => { + await login(page, DIRECTOR); + const title = `Versioned Doc ${Date.now()}`; + await page.request.post(DOCS, { + data: { data: { title, category: 'handbook_policy', body: 'v1' } }, + }); + const v1 = await findDoc(page, title); + expect(v1!.version).toBe(1); + + // Teacher acknowledges v1. + await login(page, TEACHER); + await page.request.post(ACKS, { data: { data: { policyDocumentId: v1!.id } } }); + + // Director edits → version bumps to 2. + await login(page, DIRECTOR); + const put = await page.request.put(`${DOCS}/${v1!.id}`, { + data: { id: v1!.id, data: { title, body: 'v2 updated' } }, + }); + expect(put.ok()).toBe(true); + const v2 = await findDoc(page, title); + expect(v2!.version).toBe(2); + + // Teacher re-acknowledges → a second ack row (for v2) now exists. + await login(page, TEACHER); + await page.request.post(ACKS, { data: { data: { policyDocumentId: v1!.id } } }); + const acksRes = await page.request.get(`${ACKS}?policyDocumentId=${v1!.id}`); + const acks = ((await acksRes.json()) as { rows?: AckRow[] }).rows ?? []; + expect(acks.filter((a) => a.policyDocumentId === v1!.id).length).toBeGreaterThanOrEqual(2); + }); + + test('external roles cannot read or acknowledge policies', async ({ page }) => { + await login(page, STUDENT); + expect(DENIED).toContain((await page.request.get(DOCS)).status()); + const ack = await page.request.post(ACKS, { + data: { data: { policyDocumentId: '00000000-0000-4000-8000-000000000000' } }, + }); + expect(DENIED).toContain(ack.status()); + }); +}); diff --git a/frontend/tests/e2e/product-workflow.seeded.e2e.ts b/frontend/tests/e2e/product-workflow.seeded.e2e.ts new file mode 100644 index 0000000..c5e7a0b --- /dev/null +++ b/frontend/tests/e2e/product-workflow.seeded.e2e.ts @@ -0,0 +1,88 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Product-workflow persistence e2e (Workstream 8). Proves persisted product + * workflows survive across requests (the "sees it after reload" guarantee), + * backed by the real backend rather than mock data: a director posts a FRAME + * entry and reads it back, and a staff member marks progress and reads it back. + * + * Requires the backend running with the database migrated + seeded, and the + * seed passwords in the environment. + */ +const USER_PASSWORD = 'flatlogicUser123!'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +const DIRECTOR_EMAIL = 'director@flatlogic.com'; +const TEACHER_EMAIL = 'teacher@flatlogic.com'; + +interface FrameRow { + readonly id: string; + readonly author: string; +} +interface ProgressRow { + readonly item_id: string; + readonly value: string | null; +} + +async function login(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +test.describe('Product-workflow persistence', () => { + test('a director posts a FRAME entry and reads it back', async ({ page }) => { + await login(page, DIRECTOR_EMAIL, USER_PASSWORD); + + const author = `E2E Director ${Date.now()}`; + const createRes = await page.request.post(`${BACKEND_API_URL}/frame_entries`, { + data: { + data: { + week_of: '2026-06-01', + posted_date: '2026-06-02', + formal: 'Formal note', + recognition: 'Recognition note', + application: 'Application note', + management: 'Management note', + emotional: 'Emotional note', + author, + }, + }, + }); + expect(createRes.ok()).toBe(true); + + // Re-fetch (fresh request) and confirm the entry persisted. + const listRes = await page.request.get(`${BACKEND_API_URL}/frame_entries`); + expect(listRes.status()).toBe(200); + const body = (await listRes.json()) as { rows?: FrameRow[] }; + expect((body.rows ?? []).some((row) => row.author === author)).toBe(true); + }); + + test('a staff member marks a sign learned and progress persists', async ({ + page, + }) => { + await login(page, TEACHER_EMAIL, USER_PASSWORD); + + const itemId = `sign-${Date.now()}`; + const upsertRes = await page.request.post(`${BACKEND_API_URL}/user_progress`, { + data: { + data: { progress_type: 'sign_learned', item_id: itemId, value: 'learned' }, + }, + }); + expect(upsertRes.ok()).toBe(true); + + const listRes = await page.request.get( + `${BACKEND_API_URL}/user_progress?progress_type=sign_learned&item_id=${itemId}`, + ); + expect(listRes.status()).toBe(200); + const body = (await listRes.json()) as { rows?: ProgressRow[] }; + const saved = (body.rows ?? []).find((row) => row.item_id === itemId); + expect(saved, 'the marked progress must persist').toBeTruthy(); + }); +}); diff --git a/frontend/tests/e2e/provisioning.seeded.e2e.ts b/frontend/tests/e2e/provisioning.seeded.e2e.ts new file mode 100644 index 0000000..b523bb2 --- /dev/null +++ b/frontend/tests/e2e/provisioning.seeded.e2e.ts @@ -0,0 +1,70 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Scoped provisioning e2e (Workstream 8 / §3.4, §3.7). Proves the onboarding + * contract: when a system admin creates an `owner` user with no organization, + * the backend auto-creates the company and links the owner to it. + * + * Requires the backend running with the database migrated + seeded, and the + * seed passwords in the environment. + */ +const ADMIN_PASSWORD = 'flatlogicAdmin123!'; +const ADMIN_EMAIL = 'admin@flatlogic.com'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +interface RoleRow { + readonly id: string; + readonly name: string | null; +} +interface UserRow { + readonly id: string; + readonly email: string | null; + readonly organizationId: string | null; +} + +async function login(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +test.describe('Scoped provisioning', () => { + test('creating an owner auto-creates and links the company', async ({ + page, + }) => { + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + + // Resolve the seeded `owner` role id. + const rolesRes = await page.request.get(`${BACKEND_API_URL}/roles`); + expect(rolesRes.status()).toBe(200); + const rolesBody = (await rolesRes.json()) as { rows?: RoleRow[] }; + const ownerRole = (rolesBody.rows ?? []).find((r) => r.name === 'owner'); + expect(ownerRole, 'seeded owner role must exist').toBeTruthy(); + + // Create a brand-new owner with no organization. + const email = `provisioned-owner-${Date.now()}@example.com`; + const createRes = await page.request.post(`${BACKEND_API_URL}/users`, { + data: { data: { email, app_role: ownerRole!.id } }, + }); + expect(createRes.ok()).toBe(true); + + // The owner now belongs to an auto-created company. + const lookupRes = await page.request.get( + `${BACKEND_API_URL}/users?email=${encodeURIComponent(email)}`, + ); + expect(lookupRes.status()).toBe(200); + const lookupBody = (await lookupRes.json()) as { rows?: UserRow[] }; + const created = (lookupBody.rows ?? []).find((u) => u.email === email); + expect(created, 'the provisioned owner must exist').toBeTruthy(); + expect( + created!.organizationId, + 'owner-create must auto-create and link a company', + ).toBeTruthy(); + }); +}); diff --git a/frontend/tests/e2e/rbac-access.seeded.e2e.ts b/frontend/tests/e2e/rbac-access.seeded.e2e.ts new file mode 100644 index 0000000..8dfe2b6 --- /dev/null +++ b/frontend/tests/e2e/rbac-access.seeded.e2e.ts @@ -0,0 +1,93 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Authenticated RBAC access e2e (Workstream 8 / §3.6). Uses the seeded per-role + * fixture users (Workstream 4). Requires the backend running with the database + * migrated + seeded, and the seed passwords in the environment. + */ +const ADMIN_PASSWORD = 'flatlogicAdmin123!'; +const USER_PASSWORD = 'flatlogicUser123!'; + +const ADMIN_EMAIL = 'admin@flatlogic.com'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +// Seeded fixture user ids (see backend `shared/constants/seed-fixtures.ts`). +const OWNER_ID = 'b1a7c0de-0000-4000-8000-000000000012'; + +/** Denied responses may be 400 (permission middleware) or 403 (relational policy). */ +const DENIED = [400, 401, 403]; + +async function login(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + // AuthGuard admits the shell; IndexRedirect lands on the first accessible module. + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +test.describe('RBAC route access', () => { + test('unauthenticated visitors are redirected to /login', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login/); + }); + + test('the super admin can open the director dashboard', async ({ page }) => { + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/director-dashboard'); + await expect(page).toHaveURL(/director-dashboard/); + await expect(page.getByText('404')).toHaveCount(0); + }); + + test('a teacher cannot open the director dashboard (404)', async ({ page }) => { + await login(page, 'teacher@flatlogic.com', USER_PASSWORD); + await page.goto('/director-dashboard'); + await expect(page.getByText('404')).toBeVisible(); + }); + + test('a student lands on an external page and is blocked from the dashboard', async ({ + page, + }) => { + await login(page, 'student@flatlogic.com', USER_PASSWORD); + // Role-aware landing: the first module a student can access is Community. + await expect(page).toHaveURL(/community-partnerships/); + + // Allowed external pages. + await page.goto('/vocational-opportunities'); + await expect(page.getByText('404')).toHaveCount(0); + await page.goto('/esa-funding'); + await expect(page.getByText('404')).toHaveCount(0); + + // Staff-only page is forbidden. + await page.goto('/dashboard'); + await expect(page.getByText('404')).toBeVisible(); + }); +}); + +test.describe('RBAC API enforcement', () => { + test('the super admin can list users', async ({ page }) => { + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + const res = await page.request.get(`${BACKEND_API_URL}/users`); + expect(res.status()).toBe(200); + }); + + test('a teacher cannot create a user', async ({ page }) => { + await login(page, 'teacher@flatlogic.com', USER_PASSWORD); + const res = await page.request.post(`${BACKEND_API_URL}/users`, { + data: { data: { email: 'should-not-exist@example.com' } }, + }); + expect(DENIED).toContain(res.status()); + }); + + test('a superintendent cannot delete the owner (relational policy)', async ({ + page, + }) => { + await login(page, 'superintendent@flatlogic.com', USER_PASSWORD); + const res = await page.request.delete(`${BACKEND_API_URL}/users/${OWNER_ID}`); + expect(DENIED).toContain(res.status()); + }); +}); diff --git a/frontend/tests/e2e/tenant-isolation.seeded.e2e.ts b/frontend/tests/e2e/tenant-isolation.seeded.e2e.ts new file mode 100644 index 0000000..077ff98 --- /dev/null +++ b/frontend/tests/e2e/tenant-isolation.seeded.e2e.ts @@ -0,0 +1,104 @@ +import { expect, type APIResponse, type Page, test } from '@playwright/test'; + +/** + * Cross-tenant isolation e2e (Workstream 8 / Workstream 2). Proves a non-global + * user of one organization cannot read, list, update, or delete a record owned + * by a second organization. Uses the seeded second-tenant owner (`owner2`, the + * `Rival Academy` org) and the primary-tenant owner (`owner`, `Demo Academy`). + * + * Requires the backend running with the database migrated + seeded, and the + * seed passwords in the environment. + */ +const USER_PASSWORD = 'flatlogicUser123!'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +const PRIMARY_OWNER_EMAIL = 'owner@flatlogic.com'; +const SECONDARY_OWNER_EMAIL = 'owner2@flatlogic.com'; + +const ACADEMIC_YEARS = `${BACKEND_API_URL}/academic_years`; + +interface AcademicYearRow { + readonly id: string; + readonly name: string | null; +} + +async function logout(page: Page): Promise { + await page.request.post(`${BACKEND_API_URL}/auth/signout`); + await page.context().clearCookies(); +} + +async function login(page: Page, email: string, password: string): Promise { + await logout(page); + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(password); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +async function listRows(page: Page): Promise { + const res = await page.request.get(ACADEMIC_YEARS); + expect(res.status()).toBe(200); + const body = (await res.json()) as { rows?: AcademicYearRow[] }; + return body.rows ?? []; +} + +async function findByName( + page: Page, + name: string, +): Promise { + return (await listRows(page)).find((row) => row.name === name); +} + +test.describe('Cross-tenant isolation', () => { + test('a tenant cannot read, list, mutate, or delete another tenant record', async ({ + page, + }) => { + const rivalName = `Rival Year ${Date.now()}`; + + // 1. owner2 (Rival Academy) creates a record in its own tenant. + await login(page, SECONDARY_OWNER_EMAIL, USER_PASSWORD); + const created = await page.request.post(ACADEMIC_YEARS, { + data: { data: { name: rivalName } }, + }); + expect(created.status()).toBe(200); + + const rival = await findByName(page, rivalName); + expect(rival, 'owner2 should see its own record').toBeTruthy(); + const rivalId = rival!.id; + + // 2. owner (Demo Academy) must not see it in any way. + await login(page, PRIMARY_OWNER_EMAIL, USER_PASSWORD); + + // 2a. List isolation. + const primaryRows = await listRows(page); + expect(primaryRows.some((row) => row.id === rivalId)).toBe(false); + + // 2b. Read-by-id isolation: tenant-scoped read returns no record. + const readRes = await page.request.get(`${ACADEMIC_YEARS}/${rivalId}`); + const readBody = await readRes.text(); + expect(readBody).not.toContain(rivalName); + + // 2c. Update isolation: a tenant-scoped update finds nothing → error. + const updateRes: APIResponse = await page.request.put( + `${ACADEMIC_YEARS}/${rivalId}`, + { data: { id: rivalId, data: { name: 'Hijacked' } } }, + ); + expect(updateRes.ok()).toBe(false); + + // 2d. Delete isolation: attempt to delete the rival record. + await page.request.delete(`${ACADEMIC_YEARS}/${rivalId}`); + + // 3. owner2 confirms its record still exists and is unchanged. + await login(page, SECONDARY_OWNER_EMAIL, USER_PASSWORD); + const stillThere = await findByName(page, rivalName); + expect( + stillThere, + 'the rival record must survive the cross-tenant update/delete attempts', + ).toBeTruthy(); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..5c3054c --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});