improved roles and permissions functionality, scopes drilling.

This commit is contained in:
Dmitri 2026-06-17 16:04:31 +02:00
parent 768a13ce29
commit d1a08e4c3d
465 changed files with 20470 additions and 4696 deletions

5
.gitignore vendored
View File

@ -1,7 +1,6 @@
node_modules/ node_modules/
*/node_modules/ */node_modules/
*/build/ */build/
.claude
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.env .env
@ -10,5 +9,5 @@ node_modules/
*.env.* *.env.*
!.env.example !.env.example
!*.env.example !*.env.example
.claude .codex
CLAUDE.md AGENTS.md

158
CLAUDE.md
View File

@ -1,158 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**MAIN RULE:** DON'T MAKE UP ANYTHING. If unsure, verify via project documentation, MCP tools, web search, or ask for clarification.
## Development Commands
### Backend (from `backend/`)
```bash
npm run dev # Development server with hot reload (tsx watch)
npm run verify # Run typecheck + lint + tests (use before commits)
npm run typecheck # TypeScript check only
npm run lint # ESLint only
npm run test # Node test runner (tsx --test)
npm run build # Production build → dist/
# Database
npm run db:migrate # Run pending migrations
npm run db:seed # Run seeders
npm run db:reset # Drop all → migrate → seed (destroys data)
npm run start # migrate + seed + watch (dev startup)
# VM Database Reset (requires platform credentials)
# 1. npm ci (install deps)
# 2. Get credentials: pm2 env 1 | grep DB_PASS && cat ~/executor/.env | grep DB_
# 3. DB_HOST=... DB_NAME=... DB_USER=... DB_PASS=... npm run db:reset
# 4. pm2 restart backend-dev frontend-dev --update-env
```
### Frontend (from `frontend/`)
```bash
npm run dev # Vite dev server (port 3000)
npm run build # Typecheck + Vite production build
npm run typecheck # TypeScript check only
npm run lint # ESLint only
npm run test # Vitest unit tests
npm run test:e2e # Playwright smoke tests (no backend)
npm run test:e2e:content # Playwright tests (requires backend running)
npm run test:e2e:content -- --grep "accessibility" # Accessibility tests only
```
### Root-level
```bash
npm run build:production # Build both frontend and backend
npm run start:production # Start production backend (serves API + SPA)
```
### Docker (from `docker/`)
```bash
docker compose up --build # PostgreSQL + app on localhost:8080
docker compose down -v # Stop and remove (including DB data)
```
### Quick Start (Dev Mode)
Prerequisites: PostgreSQL running locally with database `schoolchain_dev` (user: `postgres`, password: `postgres`).
```bash
# Terminal 1 - Backend (port 8080)
cd backend && npm run dev
# Terminal 2 - Frontend (port 3000)
cd frontend && npm run dev
```
- Frontend: http://localhost:3000
- Backend API: http://localhost:8080/api-docs/
Note: Use `npm run start` (not `npm run dev`) for first run to execute migrations and seeders.
### Seed Users (one per role)
Seeded by `backend/src/db/seeders/*` from `shared/constants/seed-fixtures.ts`. Passwords are
hardcoded in the seeder (see table below).
| Email | Name | Role | Scope | Password |
|---|---|---|---|---|
| `admin@flatlogic.com` | Mr. Alex Morgan | `super_admin` | system | `flatlogicAdmin123!` |
| `system_admin@flatlogic.com` | Ms. Jordan Chen | `system_admin` | system | `flatlogicAdmin123!` |
| `owner@flatlogic.com` | Mrs. Patricia Hayes | `owner` | organization | `flatlogicUser123!` |
| `superintendent@flatlogic.com` | Dr. Michael Torres | `superintendent` | organization | `flatlogicUser123!` |
| `director@flatlogic.com` | Dr. Sarah Williams | `director` | campus | `flatlogicUser123!` |
| `office_manager@flatlogic.com` | Ms. Lisa Park | `office_manager` | campus | `flatlogicUser123!` |
| `teacher@flatlogic.com` | Mrs. Emily Johnson | `teacher` | campus | `flatlogicUser123!` |
| `support_staff@flatlogic.com` | Mr. Marcus Davis | `support_staff` | campus | `flatlogicUser123!` |
| `student@flatlogic.com` | Emma Clark | `student` | external | `flatlogicUser123!` |
| `guardian@flatlogic.com` | Mr. Robert Clark | `guardian` | external | `flatlogicUser123!` |
All belong to the seeded company **Demo Academy**; campus-scoped/external users are on the **Tigers** campus. `super_admin`/`system_admin` carry `globalAccess` (no org/campus).
## Architecture
### Three-Layer Pattern (Both Frontend and Backend)
**Backend** (`backend/src/`):
```
API Layer → routes/*.ts, api/controllers/*.ts, middlewares/*.ts
Business Layer → services/*.ts (static class methods)
Data Layer → db/api/*.ts (repositories), db/models/*.ts (Sequelize)
```
**Frontend** (`frontend/src/`):
```
View Layer → pages/, components/
Business Layer → business/<module>/ (hooks, mappers, selectors)
Data Layer → shared/api/*.ts, shared/types/*.ts
```
Import direction: `API → Business → Data`. Never skip layers. Cross-cutting code lives in `shared/` and can be imported by any layer.
### Key Patterns
**CRUD Modules**: Most entities use shared factories:
- `services/shared/crud-service.ts` → one-line service config
- `api/controllers/shared/crud-controller.ts` → one-line controller config
- `api/http/crud-router.ts` → one-line router config
**Error Handling**: Throw `AppError` subclasses from `shared/errors/`. The terminal `error-handler` middleware formats responses.
**Authentication**: Backend-owned HttpOnly cookie auth. Frontend uses `AuthContext` as a thin provider; refresh/retry logic in `shared/api/httpClient.ts`.
**Import Aliases**: Use `@/*` for all imports (maps to `./src/*` in both projects).
## Working Principles
1. **Check docs first**: Read relevant docs before starting (see Documentation Entry Points below). **Before any new model/column or DB manipulation (migration/seed), consult `backend/docs/data-model-guide.md`** — reuse/wire an existing slice or reserved table instead of duplicating.
2. **Minimal changes**: Only update necessary files, prefer simple robust solutions
3. **TypeScript strict mode**: No `any` types, never disable linter/TypeScript, no type casting
4. **Concise comments**: Explain "why" not "what"
5. **No over-engineering**: Build for small SaaS. Simplest robust solution. No enterprise patterns unless required.
6. **Centralized exceptions only**: Use `AppError` subclasses from `shared/errors/`
7. **Avoid hardcoded constants**: Add to `backend/src/shared/constants/` or `frontend/src/shared/constants/`
8. **Documentation matters**: Update docs after each task; create docs for new modules
9. **Tests matter**: Update tests after each task; create tests for new functionality
10. **English only**: All documentation, comments, code, and content must be in English (except TODOs or internal plans)
## Documentation Entry Points
- Frontend architecture: `frontend/docs/frontend-architecture.md`
- Backend architecture: `backend/docs/backend-architecture.md`
- **Data model guide: `backend/docs/data-model-guide.md`** — model purpose, status, and reuse guidance. **Consult before creating any new model/column or doing DB work** (migration/seed) so existing built slices or reserved SIS tables are wired/reused instead of duplicated.
- Database schema (column reference): `backend/docs/database-schema.md` (regenerate after schema changes)
- **Backlog / remaining work: `docs/backlog.md`** — the single source for deferred work and open gaps (endpoint wiring, RBAC residuals, design-gated UIs, Phase 4/5 items, file/test/a11y). Consult it before starting work or closing gaps, and keep it updated. (The former sequenced integration plan is retired; its history is in git.)
- VM deployment: `docs/deployment-vm.md`
- Docker deployment: `docs/deployment-docker.md`
## Tech Stack
- **Backend**: Node 24, Express 5, Sequelize 6, TypeScript 6, PostgreSQL
- **Frontend**: React 19, Vite 8, TypeScript 6, Tailwind 4, React Query, Vitest, Playwright
- **Both**: ESM modules, strict TypeScript, path aliases (`@/*`)
## MCP Servers Available
- `github` - GitHub operations
- `chrome-devtools` - Browser DevTools
- `posthog` - Analytics (project: TRO Matcher)

View File

@ -67,14 +67,14 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
nullable). nullable).
Associations: `belongsTo` organization, campus, class (`classes`, as `class`), Associations: `belongsTo` organization, campus, class (`classes`, as `class`),
class_subject (`class_subjects`), taken_by (`staff`), createdBy/updatedBy (users); `hasMany` class_subject (`class_subjects`), taken_by (`users`), createdBy/updatedBy (users); `hasMany`
`attendance_records` as `attendance_records_attendance_session`. `findBy`/`GET /:id` eager-load `attendance_records` as `attendance_records_attendance_session`. `findBy`/`GET /:id` eager-load
`attendance_records_attendance_session`, organization, campus, class, class_subject, and `attendance_records_attendance_session`, organization, campus, class, class_subject, and
taken_by in a single `Promise.all`. taken_by in a single `Promise.all`.
List filters (`AttendanceSessionsFilter`): `id`, `notes` (iLike), `session_dateRange`, List filters (`AttendanceSessionsFilter`): `id`, `notes` (iLike), `session_dateRange`,
`session_type`, `campus` (id or name, `|`-separated), `class` (id or name), `class_subject` `session_type`, `campus` (id or name, `|`-separated), `class` (id or name), `class_subject`
(id or status), `taken_by` (id or `employee_number`), `organization`, `createdAtRange`, plus (id or status), `taken_by` (id or email), `organization`, `createdAtRange`, plus
`field`/`sort` ordering and `limit`/`page` pagination. `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes ## Behavior / Notes
@ -94,4 +94,4 @@ None yet.
## Related ## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`, - Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`,
`classes`, `class_subjects`, `campuses`, `staff`, `permissions.md`. `classes`, `class_subjects`, `campuses`, `users`, `permissions.md`.

View File

@ -8,9 +8,9 @@ Workstream 13 — a flexible classroom-timer sound library. A row is one of thre
`director` / `office_manager` / `teacher` add library entries; any campus staff `director` / `office_manager` / `teacher` add library entries; any campus staff
(`director`, `office_manager`, `teacher`, `support_staff`) can pick one to play in (`director`, `office_manager`, `teacher`, `support_staff`) can pick one to play in
the classroom timer. The existing **built-in timer sounds stay hardcoded global the classroom timer. The existing **built-in timer sounds stay hardcoded global
defaults** for every organization — they are served from the (global) defaults** for every organization — their metadata lives in frontend static
`content_catalog` (`classroomTimerSounds`) and synthesized client-side, so they constants and they are synthesized client-side, so they are not duplicated here.
are not duplicated here. New library entries are **campus-scoped**. New library entries are **campus-scoped**.
The **"Generate"** button in the timer creates a `recipe` row: a JSON set of 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). synthesis parameters played purely via the Web Audio API (no file, no network).
@ -28,9 +28,9 @@ visible to everyone. Exactly one of `url` / `recipe` is populated, matching
`kind` (validated in the service). `kind` (validated in the service).
For a `file` row, the binary is uploaded first through the JWT-authenticated file 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 subsystem (`POST /api/file/upload/...`) and `url` references it. Downloads are
check) and `url` references it. A `url` row holds an external link. A `recipe` JWT-only after the customer decision to remove per-file ownership checks. A `url`
row never touches the file subsystem. row holds an external link. A `recipe` row never touches the file subsystem.
## Routes (`/api/audio_files`) ## Routes (`/api/audio_files`)
@ -43,7 +43,7 @@ row never touches the file subsystem.
## Authorization ## Authorization
- `READ_AUDIO_FILES`all four campus roles (director via full access). - `READ_AUDIO_FILES`seeded for the campus audio-library audience.
- `MANAGE_AUDIO_FILES``director`, `office_manager`, `teacher` (not - `MANAGE_AUDIO_FILES``director`, `office_manager`, `teacher` (not
`support_staff`, who is read/play-only). `support_staff`, who is read/play-only).
@ -58,7 +58,7 @@ hardcoded built-ins with the `audio_files` library and groups them by origin —
**Built-in** / **Generated** / **Uploaded** — for clear structure. Playback **Built-in** / **Generated** / **Uploaded** — for clear structure. Playback
branches by kind: `builtin``playBuiltInSound(id)`, `recipe` branches by kind: `builtin``playBuiltInSound(id)`, `recipe`
`playRecipe(recipe)` (`business/classroom-timer/audio-recipe.ts`), `file`/`url` `playRecipe(recipe)` (`business/classroom-timer/audio-recipe.ts`), `file`/`url`
`new Audio(url)`. Managers (`canManageAudioFiles`) see a **Generate** button and `new Audio(url)`. Users with `MANAGE_AUDIO_FILES` see a **Generate** button and
a delete affordance on their own rows; global defaults are read-only. a delete affordance on their own rows; global defaults are read-only.
## Tests ## Tests
@ -66,20 +66,18 @@ a delete affordance on their own rows; global defaults are read-only.
- **Unit** (`npm test`): `audio-access.test.ts` (visibility/management rules) and - **Unit** (`npm test`): `audio-access.test.ts` (visibility/management rules) and
`shared/constants/audio-files.test.ts` (the `file`/`url`/`recipe` kinds + `shared/constants/audio-files.test.ts` (the `file`/`url`/`recipe` kinds +
`isAudioFileKind`). `isAudioFileKind`).
- **Frontend unit** (`vitest`): `business/audio-files/selectors.test.ts` - **Frontend unit** (`vitest`): `business/audio-files/generate.test.ts` (the
(`canManageAudioFiles`) and `generate.test.ts` (the local recipe stub shape). local recipe stub shape).
- **Seeded e2e** (`frontend/tests/e2e/audio-files.seeded.e2e.ts`, - **Seeded e2e** (`frontend/tests/e2e/audio-files.seeded.e2e.ts`,
`npm run test:e2e:content`): create/persist + same-campus read, `support_staff` `npm run test:e2e:content`): create/persist + same-campus read, `support_staff`
read-only, and external-role lockout. read-only, and external-role lockout.
## Open / deferred ## Open / deferred
- **Binary `file` upload UI** — the typed upload client is still to build, and - **Binary `file` upload UI** — the typed upload client now exists, so the audio
the download check must record a `file` row (or exempt audio) first: today upload affordance can be wired when desired. `recipe` and external `url` rows
`assertCanDownloadFile` denies any `privateUrl` with no tracked `file` row, and are unaffected (no `/file/download`).
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 - **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. 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 - If platform-global audio rows are later added, keep deletion/editing restricted
ownership check for null-organization files so the defaults stream to all. to platform-owned rows; download itself is already JWT-only.

View File

@ -33,8 +33,8 @@ tokens, or raw Sequelize model objects.
`src/db/api/auth_refresh_tokens.ts` (`AuthRefreshTokensDBApi`), `src/db/api/auth_refresh_tokens.ts` (`AuthRefreshTokensDBApi`),
`src/db/api/roles.ts` (`RolesDBApi`, used by the permission middleware). `src/db/api/roles.ts` (`RolesDBApi`, used by the permission middleware).
- Models: `src/db/models/users.ts`, `src/db/models/auth_refresh_tokens.ts`, - Models: `src/db/models/users.ts`, `src/db/models/auth_refresh_tokens.ts`,
plus the `roles`, `permissions`, `organizations`, `staff`, and `campuses` plus the `roles`, `permissions`, `organizations`, and `campuses` models
models joined for the profile. joined for the profile.
- Shared used: `shared/config` (auth/cookie/OAuth config), `shared/jwt` - Shared used: `shared/config` (auth/cookie/OAuth config), `shared/jwt`
(`jwtSign`), `shared/constants/auth.ts`, `shared/constants/roles.ts` (`jwtSign`), `shared/constants/auth.ts`, `shared/constants/roles.ts`
(role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`, (role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`,
@ -90,13 +90,14 @@ in `src/auth/auth.ts`; the Google email comes from the typed
is rejected by the strategy (`done(new Error(...))`). is rejected by the strategy (`done(new Error(...))`).
- Permission enforcement (`src/middlewares/check-permissions.ts`): - Permission enforcement (`src/middlewares/check-permissions.ts`):
- `checkPermissions(permission)` allows the request if any of: - `checkPermissions(permission)` allows the request if any of:
1. self-access bypass — `currentUser.id` equals `req.params.id` or 1. read-only self-access bypass — `currentUser.id` equals `req.params.id`
`req.body.id`; on a `GET` request;
2. global-access bypass — the user's `app_role.globalAccess` is `true` 2. super-admin bypass — the user's role is `super_admin`, which bypasses
(the system-scope roles `super_admin` / `system_admin`), which pass any standard permission checks except personal workflow permissions listed in
permission; `GLOBAL_BYPASS_EXCLUDED_PERMISSIONS`;
3. the user's `custom_permissions` include `permission`; 3. the user's `custom_permissions_filter` does not exclude `permission`;
4. the effective role's permissions include `permission`. The effective 4. the user's `custom_permissions` include `permission`;
5. the effective role's permissions include `permission`. The effective
role is the user's `app_role`, or the cached seeded `guest` role role 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 (`ROLE_NAMES.GUEST`) when there is no assigned role. The `guest` role is
fetched once at module load and cached. fetched once at module load and cached.
@ -113,7 +114,7 @@ in `src/auth/auth.ts`; the Google email comes from the typed
- The profile is loaded for the authenticated user only - The profile is loaded for the authenticated user only
(`UsersDBApi.findProfileById(currentUser.id)`), so it reflects that user's (`UsersDBApi.findProfileById(currentUser.id)`), so it reflects that user's
own organization, role, staff profile, and campus. own organization, role, and direct tenant scope.
- `signup` accepts an `organizationId` and assigns it to the created user. - `signup` accepts an `organizationId` and assigns it to the created user.
- Tenant filtering for other entities is enforced elsewhere (CRUD repositories - Tenant filtering for other entities is enforced elsewhere (CRUD repositories
scope by `currentUser.organizationId`); the auth profile endpoints do not scope by `currentUser.organizationId`); the auth profile endpoints do not
@ -123,33 +124,34 @@ in `src/auth/auth.ts`; the Google email comes from the typed
`AuthService.currentUserProfile` returns (built from `findProfileById`): `AuthService.currentUserProfile` returns (built from `findProfileById`):
- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName` - `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName`, `phoneNumber`
- `organizationId` - `organizationId`
- `organizations``OrganizationDto` `{ id, name }` or `null` - `organizations``OrganizationDto` `{ id, name }` or `null`
- `app_role``RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name` - `app_role``RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name`
is one of the 11 first-class role names and `scope` is its scope is one of the first-class role names and `scope` is its scope
(`system` | `organization` | `campus` | `external` | `guest`); the frontend (`system` | `organization` | `school` | `campus` | `class` | `external` |
`guest`); the frontend
derives the UI role from `app_role.name`. There is no separate `productRole`. derives the UI role from `app_role.name`. There is no separate `productRole`.
- `staffProfile``StaffProfileDto`
`{ id, employee_number, job_title, staff_type, status, organizationId,
campusId, userId }` or `null` (first row of `staff_user`)
- `campus``CampusDto` `{ id, name, code }` or `null` - `campus``CampusDto` `{ id, name, code }` or `null`
- `campusId` — the campus DTO id, else the staff profile `campusId`, else `null` - `campusId` — the user's direct campus scope id, else the campus DTO id, else `null`
- `permissions` — de-duplicated string names from the role's permissions plus - `permissions` — effective permission names: role permissions plus
the user's `custom_permissions` `custom_permissions`, minus `custom_permissions_filter`.
Note: the profile payload does not include a `phoneNumber` field
(`findProfileById` does not select it and `currentUserProfile` does not return
it).
Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts` Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts`
`ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by `ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by
`db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the `db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the
two system roles, `globalAccess: true`. The preset permission matrix grants two system roles, `globalAccess: true`. The preset permission matrix grants
`owner` / `superintendent` / `director` every permission, `office_manager` / `owner` / `superintendent` / `principal` / `director` every permission,
`teacher` / `support_staff` read-only entity permissions, and `student` / `registrar` / `office_manager` / `teacher` / `support_staff` read-only entity
`guardian` / `guest` none; `super_admin` / `system_admin` need no rows (they permissions, and `student` / `guardian` / `guest` no entity CRUD permissions;
bypass via `globalAccess`). Per-user `custom_permissions` extend a user's grants. `guardian` still receives the parent-communication product permission.
`system_admin` also receives explicit role-permission rows and is processed like
every other permission-based role; `globalAccess` still gives it platform-wide
tenant reach. Only `super_admin` keeps the standard permission bypass. Personal
workflow permissions (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`) are
excluded from that bypass and must be explicitly present. Per-user
`custom_permissions` extend a user's grants; `custom_permissions_filter`
removes specific permissions from the role grant.
Signup / signin behavior (`src/services/auth.ts`): Signup / signin behavior (`src/services/auth.ts`):
@ -179,7 +181,9 @@ Signup / signin behavior (`src/services/auth.ts`):
## Tests ## Tests
None yet (no auth unit/e2e test under `backend/src`). - `src/services/auth.test.ts` covers auth/profile service behavior.
- `src/api/controllers/auth.controller.test.ts` covers auth controller request
handling.
## Related ## Related

View File

@ -21,6 +21,8 @@ Location:
`paramStr`). `paramStr`).
- `src/middlewares/``authenticate` (passport), `checkPermissions`, - `src/middlewares/``authenticate` (passport), `checkPermissions`,
`csrf-origin`, `error-handler`, `upload`. `csrf-origin`, `error-handler`, `upload`.
- `src/commands/` — CLI/maintenance entrypoints. Commands are API-layer
adapters: parse/run the operation, call BLL services, and own process output.
Responsibilities: Responsibilities:
@ -40,10 +42,9 @@ The API layer must not:
Every `/api` route is JWT-authenticated at the mount (`authenticated = passport.authenticate('jwt', { session: false })`) **except** the intentionally public surface: 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); - 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/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(<PRODUCT_FEATURE>)` for page reads and the special actions (`READ_FRAME`, `READ_WALKTHROUGH`, `READ_ATTENDANCE`, `READ_PARENT_COMM`, `READ_INTERNAL_COMM`, `FILL_ATTENDANCE`, `TAKE_QUIZ` — names from `shared/constants/product-permissions.ts`, so `custom_permissions` can extend access), while the manager-only writes (FRAME/walkthrough/communications/content-catalog editing and the staff/attendance reports) stay gated in their services by role until a dedicated `MANAGE_*` permission exists. The `users` / `staff` / `organizations` write paths add the §3.3 relational policy. Both `POST /api/file/upload` and `GET /api/file/download` require JWT, the local file handlers reject path traversal, and download enforces a per-file tenant/ownership check (the file's owning organization must match the requester's unless they have global access; see `file.md`). No tenant-owned mutable data is exposed publicly. Content catalog reads now use authenticated `GET /api/content-catalog/read/:contentType` so scoped content can resolve from the current user. Authorization is then by effective permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions(<PRODUCT_FEATURE>)` for page reads and special actions (`READ_FRAME`, `READ_WALKTHROUGH`, `READ_ATTENDANCE`, `READ_PARENT_COMM`, `READ_INTERNAL_COMM`, `FILL_ATTENDANCE`, `TAKE_QUIZ` — names from `shared/constants/product-permissions.ts`). Service-level feature gates use dedicated permissions such as `MANAGE_FRAME`, `MANAGE_WALKTHROUGH`, `MANAGE_CONTENT_CATALOG`, and report-read permissions. `globalAccess` expands tenant reach to platform scope; only `super_admin` bypasses the standard management/page permission checks, and even that bypass excludes personal workflow permissions (`READ_PARENT_COMM`, `ACK_POLICY`, `ZONE_CHECKIN`), which still require explicit grants. The User Admin `custom_permissions` / `custom_permissions_filter` controls can therefore add or remove these feature grants for tenant users, including `system_admin`. The `users` / `organizations` write paths still add the §3.3 relational policy because hierarchy constraints cannot be expressed as flat permissions. Both `POST /api/file/upload` and `GET /api/file/download` require JWT, and the local file handlers reject path traversal; downloads are JWT-only after the customer decision to remove per-file ownership checks (see `file.md`).
## Layer 2: Business Logic (BLL) ## Layer 2: Business Logic (BLL)
@ -56,7 +57,7 @@ Location:
Responsibilities: Responsibilities:
- Own workflows, transactions, and coordination across repositories. - Own workflows, transactions, and coordination across repositories.
- Apply tenant, role, campus, and permission rules. - Apply tenant, scope, campus, and permission rules.
- Map DB records to response DTOs; validate and normalize inputs. - Map DB records to response DTOs; validate and normalize inputs.
- Accept typed inputs and return typed values/DTOs. - Accept typed inputs and return typed values/DTOs.
@ -75,6 +76,7 @@ Location:
- `src/db/api/` — one `*DBApi` class per entity (the repository layer). - `src/db/api/` — one `*DBApi` class per entity (the repository layer).
- `src/db/models/` — Sequelize models. - `src/db/models/` — Sequelize models.
- `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`. - `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`.
- `src/db/reset.ts`, `src/db/umzug.ts`, and other DB operational helpers.
- `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`, - `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`,
`CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`. `CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`.
@ -151,10 +153,10 @@ Most modules are assembled from shared factories/helpers — keep them that way.
`findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to `findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to
`db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`, `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
`autocompleteByField`). `autocompleteByField`).
- **Feature service (BLL)** = reuse shared helpers: tenant/role access in - **Feature service (BLL)** = reuse shared helpers: tenant/scope access in
`services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`, `services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`,
`hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`, `hasGlobalAccess`, `requireUserId`, `hasFeaturePermission`,
`campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …); `scopeDimensionWhere`, `assertAuthenticatedTenantUser`, …);
validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`, validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`,
`requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts` `requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts`
(`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`; (`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`;
@ -200,8 +202,17 @@ silently using insecure defaults.
## Enforcement & verification ## Enforcement & verification
- `src/shared/architecture/import-boundaries.test.ts` enforces the import - `src/shared/architecture/import-boundaries.test.ts` enforces the import
direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL direction. Every production `.ts` file must be assigned to a layer (test files,
edge cases and the one DAL→BLL leak are capped by ceilings that must not grow. declarations, and `test-utils/` are excluded). The test resolves alias and
relative project imports, treats all `src/db/**` files as DAL, forbids
unapproved cross-layer edges, and verifies exact allowlists so stale exceptions
are removed instead of silently accumulating.
- Current exact architecture exceptions are:
- `auth/auth.ts -> @/db/api/users` and
`middlewares/check-permissions.ts -> @/db/api/roles` for API edge wiring.
- `services/auth.ts -> express`, `services/file.ts -> express`, and
`services/file.ts -> @/middlewares/upload` for remaining HTTP-in-BLL cases.
- `db/api/file.ts -> @/services/file` for the file-storage deletion bridge.
- ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the - ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the
already-clean invariants at lint time (API→DAL, model/DAL/shared purity). already-clean invariants at lint time (API→DAL, model/DAL/shared purity).
- `npm run typecheck`, `npm run lint`, `npm test` are the verification gates; - `npm run typecheck`, `npm run lint`, `npm test` are the verification gates;

View File

@ -25,13 +25,14 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_
## Access Rules ## Access Rules
- All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`). - All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`).
- Mutations (`PUT` config / summary) additionally require manage access (`assertCanManageCampusAttendance`): the user must hold one of `CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES` (`super_admin`, `system_admin`, `owner`, `superintendent`, `director`, `office_manager`). Global-access roles pass `hasRoleAccess`. - Mutations (`PUT` config / summary) additionally require `FILL_ATTENDANCE` (`assertCanManageCampusAttendance`). Global-access users still pass through the shared global-access permission bypass.
- Campus-key access (`assertCanAccessCampusKey`): 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`. - Campus-key access (`assertCanAccessCampusKey`): organization/global active scope may access any campus key in the active organization; school scope may access campus keys under the active school; campus/class scope may access only the current campus key. A mismatch or missing campus key throws `ForbiddenError`.
- The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`). - The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`).
- Organization and school attendance entry still writes a campus-level summary. The aggregate screens choose a scoped campus and call the same `PUT /summaries/:campusKey/:date` endpoint; organization/school totals are read-time aggregates, not separate rows.
## Tenant Scope ## Tenant Scope
- Every read and write filters by `organizationId: requireOrganizationId(currentUser)`. - Every read and write filters by `organizationId: requireOrganizationId(currentUser)`.
- `campusScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; tenant-wide roles with no requested key see all campus keys (no `campus_key` filter); other users are restricted to their own derived `campus_key`, and users with no derivable campus key are rejected with `ForbiddenError`. - `campusKeyScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; organization/global active scope with no requested key sees all campus keys in the active organization; school scope with no requested key sees all campus keys under the active school; campus/class scope is restricted to the current campus key, and users with no derivable campus key are rejected with `ForbiddenError`.
- On upsert, the existing-row lookup keys on `organizationId` + `campus_key` (config) or `organizationId` + `campus_key` + `attendance_date` (summary). - On upsert, the existing-row lookup keys on `organizationId` + `campus_key` (config) or `organizationId` + `campus_key` + `attendance_date` (summary).
## Data Contract ## Data Contract
@ -54,7 +55,8 @@ Per the customer decision (2026-06-11), the **source of truth for campus attenda
**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row. **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 ## Tests
None yet (no `*.test.ts` under `backend/src` references this slice). - `src/api/controllers/campus_attendance.controller.test.ts` covers controller delegation.
- `src/services/campus_attendance.test.ts` covers active campus drill-down access by resolving campus UUID to the stored campus key.
## Related ## Related
- Frontend: `frontend/docs/campus-attendance-integration.md`. - Frontend: `frontend/docs/campus-attendance-integration.md`.

View File

@ -74,7 +74,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
`createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`. `createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`.
Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy
(users); `hasMany` `staff_campus`, `classes_campus`, `timetables_campus`, (users); `hasMany` `classes_campus`, `timetables_campus`,
`attendance_sessions_campus`, `messages_campus`, `documents_campus` (all keyed on `attendance_sessions_campus`, `messages_campus`, `documents_campus` (all keyed on
`campusId`, `constraints: false`). `campusId`, `constraints: false`).
@ -105,4 +105,4 @@ None yet.
- Public read surface: `campus-catalog.md` (`GET /api/public/campuses`, shares the - 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). `src/db/models/campuses.ts` model but not the `src/db/api/campuses.ts` repository).
- Generic-CRUD contract: `backend-architecture.md`. - Generic-CRUD contract: `backend-architecture.md`.
- Related slices: `staff`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`. - Related slices: `users`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`.

View File

@ -3,7 +3,7 @@
## Purpose ## Purpose
`class_subjects` is the per-organization join between `classes` and `subjects` — it represents a `class_subjects` is the per-organization join between `classes` and `subjects` — it represents a
subject taught in a class, optionally assigned to a teacher (staff). It is a generic-CRUD slice subject taught in a class, optionally assigned to a teacher user. It is a generic-CRUD slice
assembled from the shared factories; the backend is the source of truth for these assignments. assembled from the shared factories; the backend is the source of truth for these assignments.
## Slice Files (by layer) ## Slice Files (by layer)
@ -62,7 +62,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
- `importHash` (unique), `organizationId`, `classId`, `subjectId`, `teacherId`, `createdById`, - `importHash` (unique), `organizationId`, `classId`, `subjectId`, `teacherId`, `createdById`,
`updatedById`, timestamps. `updatedById`, timestamps.
Associations: `belongsTo` organization, class (classes), subject (subjects), teacher (staff), Associations: `belongsTo` organization, class (classes), subject (subjects), teacher (users),
createdBy/updatedBy (users); `hasMany` `timetable_periods_class_subject`, createdBy/updatedBy (users); `hasMany` `timetable_periods_class_subject`,
`attendance_sessions_class_subject`, `assessments_class_subject`. `findBy`/`GET /:id` eager-load `attendance_sessions_class_subject`, `assessments_class_subject`. `findBy`/`GET /:id` eager-load
timetable_periods_class_subject, attendance_sessions_class_subject, assessments_class_subject, timetable_periods_class_subject, attendance_sessions_class_subject, assessments_class_subject,
@ -70,7 +70,7 @@ organization, class, subject and teacher in a single `Promise.all` (the class as
exposed on the output as `class`). exposed on the output as `class`).
List filters (`ClassSubjectsFilter`): `id`, `class` (id or name, `|`-separated), `subject` (id or List filters (`ClassSubjectsFilter`): `id`, `class` (id or name, `|`-separated), `subject` (id or
name, `|`-separated), `teacher` (id or `employee_number`, `|`-separated), `status`, name, `|`-separated), `teacher` (id or email, `|`-separated), `status`,
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. `organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes ## Behavior / Notes
@ -90,4 +90,4 @@ None yet.
## Related ## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `subjects`, - Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `subjects`,
`staff`, `timetable_periods`, `attendance_sessions`, `assessments`, `permissions.md`. `users`, `timetable_periods`, `attendance_sessions`, `assessments`, `permissions.md`.

View File

@ -63,14 +63,14 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
`homeroom_teacherId`, `createdById`, `updatedById`, timestamps. `homeroom_teacherId`, `createdById`, `updatedById`, timestamps.
Associations: `belongsTo` organization, campus, academic_year (academic_years), grade (grades), Associations: `belongsTo` organization, campus, academic_year (academic_years), grade (grades),
homeroom_teacher (staff), createdBy/updatedBy (users); `hasMany` `class_enrollments_class`, homeroom_teacher (users), createdBy/updatedBy (users); `hasMany` `class_enrollments_class`,
`class_subjects_class`, `attendance_sessions_class`. `findBy`/`GET /:id` eager-load `class_subjects_class`, `attendance_sessions_class`. `findBy`/`GET /:id` eager-load
class_enrollments_class, class_subjects_class, attendance_sessions_class, organization, campus, class_enrollments_class, class_subjects_class, attendance_sessions_class, organization, campus,
academic_year, grade and homeroom_teacher in a single `Promise.all`. academic_year, grade and homeroom_teacher in a single `Promise.all`.
List filters (`ClassesFilter`): `id`, `name` (ilike), `section` (ilike), `capacityRange`, List filters (`ClassesFilter`): `id`, `name` (ilike), `section` (ilike), `capacityRange`,
`status`, `campus` (id or name, `|`-separated), `academic_year` (id or name, `|`-separated), `status`, `campus` (id or name, `|`-separated), `academic_year` (id or name, `|`-separated),
`grade` (id or name, `|`-separated), `homeroom_teacher` (id or `employee_number`, `|`-separated), `grade` (id or name, `|`-separated), `homeroom_teacher` (id or email, `|`-separated),
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. `organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes ## Behavior / Notes
@ -89,5 +89,5 @@ None yet.
## Related ## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `class_enrollments`, - Generic-CRUD contract: `backend-architecture.md`; related slices: `class_enrollments`,
`class_subjects`, `campuses`, `academic_years`, `grades`, `staff`, `attendance_sessions`, `class_subjects`, `campuses`, `academic_years`, `grades`, `users`, `attendance_sessions`,
`permissions.md`. `permissions.md`.

View File

@ -1,59 +1,79 @@
# Communications Backend # Communications Backend
## Purpose ## Purpose
The communications slice exposes product-focused endpoints for parent messages and internal alert events, instead of the generated CRUD routes. Parent messages reuse the existing `messages` and `message_recipients` tables; internal alerts own the `communication_events` table. The backend is the source of truth for these records. The communications area exposes two product-focused flows instead of generic CRUD:
- Parent/guardian direct messages through `direct_messages`.
- Internal alert events through `communication_events`.
## Slice Files (by layer) ## Slice Files (by layer)
- Route: `src/routes/communications.ts` (thin wiring; `GET /parent-messages`, `POST /parent-messages`, `GET /events`, `POST /events`). Mounted at `/api/communications` behind the `authenticated` middleware in `src/index.ts`. - Route: `src/routes/communications.ts` (thin wiring; `GET /events`, `POST /events`). Mounted at `/api/communications` behind the `authenticated` middleware in `src/index.ts`.
- Controller: `src/api/controllers/communications.controller.ts` (custom — `listParentMessages`, `createParentMessage`, `listEvents`, `createEvent`). - Controller: `src/api/controllers/communications.controller.ts` (custom — `listEvents`, `createEvent`).
- Service (BLL): `src/services/communications.ts` (+ `src/services/communications.types.ts`). Contains validation, scope resolution, and DTO mappers. - 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). - Repository (DAL): queries run through `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). - Models: `src/db/models/communication_events.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`. - Shared used: `services/shared/access.ts`, `shared/constants/communications.ts` (event-type values and manager role list), `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
Direct messages:
- Route: `src/routes/direct_messages.ts`, mounted at `/api/direct_messages`, authenticated and gated by `READ_PARENT_COMM`.
- Controller: `src/api/controllers/direct_messages.controller.ts`.
- Service: `src/services/direct_messages.ts`.
- Model: `src/db/models/direct_messages.ts`.
## API ## API
All routes require JWT authentication. All routes require JWT authentication.
- `GET /api/communications/parent-messages` -> `200` `{ rows, count }`. Optional query `category`; supports `limit`/`page`.
- `POST /api/communications/parent-messages` -> `201` the created parent-message DTO. Body is `req.body.data`.
- `GET /api/communications/events` -> `200` `{ rows, count }`. Optional query `type`; supports `limit`/`page`. - `GET /api/communications/events` -> `200` `{ rows, count }`. Optional query `type`; supports `limit`/`page`.
- `POST /api/communications/events` -> `201` the created event DTO. Body is `req.body.data`. - `POST /api/communications/events` -> `201` the created event DTO. Body is `req.body.data`.
- `PATCH /api/communications/events/:id` -> `200` the updated event DTO. Body is `req.body.data`.
- `DELETE /api/communications/events/:id` -> `204`. Soft-deletes a wrongly-created alert without creating a user-facing notification.
- `POST /api/communications/events/:id/cancel` -> `201` the cancellation notification DTO. Body is `req.body.data` with optional `reason`.
- `GET /api/direct_messages/contacts` -> `200` `{ rows }`, contacts available through a shared student.
- `GET /api/direct_messages/conversations` -> `200` `{ rows }`, one row per `otherUserId + studentId`.
- `GET /api/direct_messages/thread/:otherUserId?studentId=:studentId` -> `200` the isolated thread for that staff/guardian/student context; marks incoming messages in that context as read.
- `POST /api/direct_messages/send` -> `200` the created message. Body is `req.body.data` with `recipientId`, `body`, and `studentId`.
Parent-message DTO fields: `id`, `text` (from `body`), `to` (first recipient `recipient_label`), `date` (ISO, from `sent_at` or `createdAt`), `category` (derived from `subject`), `sentAt`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event_type`), `targetLevel`, `roles`, `organizationId`, `schoolId`, `campusId`, `classId`, `canceledEventId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event_type`), `roles`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`. Direct-message contact/conversation rows include `conversationKey`, `userId`, `name`, `role`, `studentId`, and `studentName`. `conversationKey` is derived from `userId + studentId`; it is a client key, not a stored column.
## Access Rules ## Access Rules
- All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`). - All endpoints require an authenticated user. Tenant-scoped alerts require a tenant context; platform-scope alerts are allowed for global-access managers.
- `POST /events` additionally requires manage access (`assertCanManageCommunications`): the user must hold one of `COMMUNICATION_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`. - `POST /events` additionally requires `MANAGE_INTERNAL_COMM`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users.
- Listing and creating parent messages requires only an authenticated tenant user. - `PATCH /events/:id`, `DELETE /events/:id`, and `POST /events/:id/cancel` are allowed for the original creator or a manager with `MANAGE_INTERNAL_COMM` in the alert's scope. Global admins can mutate tenant alerts from platform root, but they do not see other users' tenant alerts in the root list; they see them through tenant drill-down.
- Alert create accepts exact targets: `system`, `all`, `organization`, `school`, and `campus`. Class-level create/targeting is intentionally not supported; class-scope users read campus-level alerts.
- Global root managers can create `system`, `all`, and selected organization/school/campus alerts. Organization managers can target their own organization, schools, or campuses. School managers can target their own school or campuses. Campus managers can target their own campus only.
- Direct messages require `READ_PARENT_COMM`. The granted audience is `office_manager`, `teacher`, and `guardian`.
- Direct-message access is membership-based and student-context based:
- Guardians can find the teacher and office manager connected to each linked student.
- Teachers can find guardians for students in their class.
- Office managers can find guardians for students on their campus.
- Threads and sends are allowed only when the requested `studentId` matches one of those contact rows.
- The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user. - The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user.
## Tenant Scope ## Tenant Scope
- `GET /parent-messages` filters by organization via `getOrganizationIdOrGlobal(currentUser)`: - Internal alerts are stored with an exact target. `targetLevel = system` with a null tenant chain is visible only in the platform/system scope. `targetLevel = all` with a null tenant chain is a platform-wide broadcast.
global access users see messages across all organizations; regular users see only their own org. - List visibility includes the current scope and descendant targets. Platform-root users see platform alerts plus tenant-target alerts they created. When they drill into a tenant, they see that tenant scope like a scoped viewer. Organization users see their organization alerts plus school/campus alerts inside that organization. School users see their school alerts plus campus alerts inside that school. Campus/class users see their campus alerts only.
Global access users also see all users' messages; regular users see only their own (`createdById`). - Parent alerts do not automatically propagate down: an organization alert is not a campus alert unless the sender also targets that campus. Class-scope users read campus-level alerts because class content is campus-level.
Audience is always `guardians`, plus `campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES)`. - Selecting multiple tenant audiences creates multiple `communication_events` rows with the same title/date/type and different exact target stamps.
- `GET /events` filters by organization via `getOrganizationIdOrGlobal(currentUser)` plus the same - Direct messages are not tenant-broadcast records. Contacts and threads are resolved through the current user's student links, class, or campus.
`campusScope`. Global access users see events across all organizations.
- `campusScope` (from `services/shared/access.ts`): tenant-wide roles (`COMMUNICATION_TENANT_WIDE_ROLE_NAMES` — super admin, admin, platform owner, tenant director) or global access see all of the organization; other users are restricted to their profile `campusId` when one is present.
- On create, `organizationId` and `campusId` are derived from the user (`getOrganizationIdOrGlobal`, `getCampusId`).
## Data Contract ## 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`), `targets` (optional array of `{ level, id }`; omitted targets default to the creator's exact scope), `roles` (legacy metadata; it is not used for visibility).
- 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), `targetLevel` (text: `system`, `all`, `organization`, `school`, `campus`), `roles` (JSONB, default `[]`), nullable `organizationId`, nullable `schoolId`, nullable `campusId`, nullable `classId`, nullable `canceledEventId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, `belongsTo` associations to `organizations`, `campuses`, `users` (createdBy, updatedBy).
- `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)`. - List pagination: both lists use `resolvePagination(limit, page)`.
## Behavior / Notes ## Behavior / Notes
- `createParentMessage` runs inside `withTransaction`: creates a `messages` row (`subject = category`, `body = messageText`, `channel = in_app`, `audience = guardians`, `sent_at = now`, `status = sent`) and a matching `message_recipients` row (`recipient_type = guardian`, `recipient_label = recipientName`, `delivery_status = sent`, `delivered_at = now`), then re-reads the message with its recipient to build the DTO. - Direct-message conversations are separated by student. The same guardian and teacher can have separate threads for two different students because reads/writes filter by `sender/recipient pair + studentId`.
- Parent-message list includes `message_recipients` (alias `message_recipients_message`, only `recipient_label`) and orders by `sent_at desc`, then `createdAt desc`. - Event list orders by `event_date asc`, then `createdAt desc`. `createEvent` creates one row per exact target. `cancelEvent` creates a new cancellation notification with the same target stamp and soft-deletes the original alert; `deleteEvent` only soft-deletes the original alert.
- Event list orders by `event_date asc`, then `createdAt desc`. `createEvent` is a single create (no transaction).
- Validation failures throw `ValidationError`; access failures throw `ForbiddenError`. - Validation failures throw `ValidationError`; access failures throw `ForbiddenError`.
## Tests ## Tests
None yet (no `*.test.ts` under `backend/src` references this slice). - `src/services/direct_messages.test.ts` covers contact discovery through linked
students, guardian/staff contact visibility, student-separated conversations,
ambiguous same-counterpart thread rejection, and persisted `studentId`
context when sending.
## Related ## Related
- Frontend: `frontend/docs/communications-integration.md`. - Frontend: `frontend/docs/communications-integration.md`.
- Related backend slice: content catalog (`backend/docs/content-catalog.md`) backs safety protocols and parent-message templates referenced by the communications UI. - Related backend slice: direct messages (`src/services/direct_messages.ts`) backs the guardian/staff Messages UI.

View File

@ -1,23 +1,21 @@
# Content Catalog Backend # Content Catalog Backend
## Purpose ## Purpose
`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for these domain/content records, instead of duplicating them in frontend runtime constants. A public read endpoint serves the active payload for a content type, and authenticated management endpoints allow runtime configuration of catalog records. `content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for editable/scoped domain/content records. Product-static catalogs such as personality quiz content and classroom-timer presets live in frontend constants instead. Authenticated reads return the active payload scoped to the current user, and authenticated management endpoints allow runtime configuration of catalog records.
## Slice Files (by layer) ## Slice Files (by layer)
- Routes: - Routes:
- `src/routes/public_content_catalog.ts` — public read (`GET /:contentType`). Mounted at `/api/public/content-catalog` in `src/index.ts` (NOT behind the `authenticated` middleware). - `src/routes/content_catalog.ts` — authenticated read (`GET /read/:contentType`) plus management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware.
- `src/routes/content_catalog.ts` — management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware.
- Controllers: - Controllers:
- `src/api/controllers/public_content_catalog.controller.ts` (`findByType`). - `src/api/controllers/content_catalog.controller.ts` (`readByType`, `list`, `create`, `findManagedByType`, `update`, `remove`).
- `src/api/controllers/content_catalog.controller.ts` (`list`, `create`, `findManagedByType`, `update`, `remove`).
- Service (BLL): `src/services/content_catalog.ts` (single `ContentCatalogService`: `list`, `findByType`, `findManagedByType`, `create`, `update`, `delete`). - Service (BLL): `src/services/content_catalog.ts` (single `ContentCatalogService`: `list`, `findByType`, `findManagedByType`, `create`, `update`, `delete`).
- Repository (DAL): queries run through `db.content_catalog` inside the service (no separate `db/api` file). - Repository (DAL): queries run through `db.content_catalog` inside the service (no separate `db/api` file).
- Model: `src/db/models/content_catalog.ts` (no model associations). - Model: `src/db/models/content_catalog.ts` (no model associations).
- Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasRoleAccess`), `shared/constants/content-catalog.ts` (`CONTENT_CATALOG_MANAGER_ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`. - Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasFeaturePermission`, tenant helpers), `shared/constants/content-catalog.ts`, `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
- Seeds: `src/db/seeders/20260608103000-content-catalog.ts` with payloads in `src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`. - Seeds: `src/db/seeders/20260608103000-content-catalog.ts` with payloads in `src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`.
## API ## API
- `GET /api/public/content-catalog/:contentType` -> `200` the active content DTO for that `contentType`. No JWT required. Throws `ValidationError('contentCatalogNotFound')` when no active record exists. - `GET /api/content-catalog/read/:contentType` -> `200` the active content DTO for that `contentType`, scoped to the authenticated user. Throws `ValidationError('contentCatalogNotFound')` when no active record exists.
- `GET /api/content-catalog` -> `200` `{ rows, count }`. JWT + manage access. Supports `limit`/`page`. - `GET /api/content-catalog` -> `200` `{ rows, count }`. JWT + manage access. Supports `limit`/`page`.
- `POST /api/content-catalog` -> `201` the created DTO. JWT + manage access. Body is `req.body.data`. - `POST /api/content-catalog` -> `201` the created DTO. JWT + manage access. Body is `req.body.data`.
- `GET /api/content-catalog/:contentType` -> `200` the active DTO for that type. JWT + manage access (delegates to `findByType`). - `GET /api/content-catalog/:contentType` -> `200` the active DTO for that type. JWT + manage access (delegates to `findByType`).
@ -27,35 +25,44 @@
DTO fields: `id`, `content_type`, `payload`, `updatedAt`. DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
## Access Rules ## Access Rules
- The public read endpoint (`/api/public/content-catalog/:contentType`) is unauthenticated and applies no role check; it only returns records where `active = true`. - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`.
- All `/api/content-catalog` management endpoints require manage access (`assertCanManageContentCatalog`): the user must hold one of `CONTENT_CATALOG_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`. (`hasRoleAccess` is the only gate; there is no separate `assertAuthenticatedTenantUser` call in this service.) - All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
## Tenant Scope ## Tenant Scope
None. `content_catalog` has no `organizationId`/`campusId` columns and the service applies no tenant or campus filtering; records are global. `content_type` is unique across the table. Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns:
- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level.
- School-scoped types read the caller's resolved school row.
- Org-scoped types read the caller's organization row.
- Shared/global types use all-null tenant ids.
Management list excludes tenant-scoped content because those records are edited through dedicated per-type editors.
## Data Contract ## Data Contract
- Create input: `content_type` (required non-empty string), `payload` (required; any non-`undefined` JSON value), optional `active` (defaults to `true` unless explicitly `false`), optional `importHash`. - Create input: `content_type` (required non-empty string), `payload` (required; any non-`undefined` JSON value), optional `active` (defaults to `true` unless explicitly `false`), optional `importHash`.
- Update input: `payload` (required), optional `active` (set to `true` unless explicitly `false`). - Update input: `payload` (required), optional `active` (set to `true` unless explicitly `false`).
- Model fields: `id` (UUID), `content_type` (text, unique, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`. - Model fields: `id` (UUID), `content_type` (text, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), nullable tenant ids (`organizationId`, `schoolId`, `campusId`, `classId`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`.
- List pagination: `list` uses `resolvePagination(limit, page)` and orders by `content_type asc`. - List pagination: `list` uses `resolvePagination(limit, page)` and orders by `content_type asc`.
## Behavior / Notes ## Behavior / Notes
- `create` looks up any existing row by `content_type` with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created. - `create` looks up any existing row by `content_type` plus its exact tenant owner with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`). - `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads. - Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
### Seeded content types ### Seeded content types
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `parent-message-templates`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `personality-quiz-questions`, `personality-types`, `personality-workplace-content`, `esa-funding-content`, `safety-protocols`, `classroom-timer-backgrounds`, `classroom-timer-sounds`, `classroom-timer-presets`, `classroom-timer-tips`, `personality-quiz-features`. The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `esa-funding-content`.
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records.
### Content authoring rules ### Content authoring rules
- Add production content records to backend seed payloads, not frontend constants. - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.
- Frontend constants stay limited to UI config, labels, query keys, timing values, and presentation tokens. - Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs.
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
## Tests ## Tests
None yet (no `*.test.ts` under `backend/src` references this slice). Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`.
## Related ## Related
- Frontend: `frontend/docs/content-catalog-integration.md`. - Frontend: `frontend/docs/content-catalog-integration.md`.
- Related backend slice: communications (`backend/docs/communications.md`) — its UI consumes `parent-message-templates` and `safety-protocols` from this catalog. - Related backend slice: communications (`backend/docs/communications.md`) covers internal alerts and direct guardian/staff messages.

View File

@ -161,7 +161,7 @@ grace window:
```bash ```bash
npm run db:cleanup-tokens # dev (tsx) npm run db:cleanup-tokens # dev (tsx)
node dist/db/cleanup-refresh-tokens.js # prod (built; or npm run db:cleanup-tokens:prod) node dist/commands/cleanup-refresh-tokens.js # prod (built; or npm run db:cleanup-tokens:prod)
``` ```
- **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The - **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The

View File

@ -26,8 +26,8 @@ Also: every tenant-owned table carries `organizationId` (+ optional `campusId`)
| Campus daily attendance (working `/attendance`) | **`campus_attendance_config` + `campus_attendance_summaries`** | manual **aggregate** entry by `office_manager` (`FILL_ATTENDANCE`). NOT per-student rows. | | Campus daily attendance (working `/attendance`) | **`campus_attendance_config` + `campus_attendance_summaries`** | manual **aggregate** entry by `office_manager` (`FILL_ATTENDANCE`). NOT per-student rows. |
| Staff attendance | **`staff_attendance_records`** | the staff-attendance slice. | | Staff attendance | **`staff_attendance_records`** | the staff-attendance slice. |
| Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). | | Weekly F.R.A.M.E. entry | **`frame_entries`** | `week_of` is the canonical Sunday-start ISO (`shared/constants/week.ts`). |
| Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned **and** the daily zone check-in (`item_id` = campus-local date). | | Per-user progress / daily self-state | **`user_progress`** | `progress_type` + `item_id` + `value`. Backs sign-learned, Classroom Support favorites, and the daily zone check-in (`item_id` = campus-local date). |
| Backend-owned editable content | **`content_catalog`** | global JSONB payloads by `content_type`. | | Backend-owned editable content | **`content_catalog`** | scoped/editable JSONB payloads by `content_type`; truly global static catalogs stay in frontend constants. |
| File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. | | File upload/download | the **file subsystem** + `file` table | see `file.md`; downloads enforce per-file ownership. |
## Reserved SIS cluster — kept but **not yet wired** ## Reserved SIS cluster — kept but **not yet wired**
@ -43,7 +43,7 @@ a coherent academic/SIS graph:
- **`grades`** — grade **levels** (Grade 1, K…: `name`, `code`, `sort_order`). NOT marks. A `class` belongs to a grade. - **`grades`** — grade **levels** (Grade 1, K…: `name`, `code`, `sort_order`). NOT marks. A `class` belongs to a grade.
- **`subjects`** — the reusable **subject catalog** (Math, English: `name`, `code`, `description`). - **`subjects`** — the reusable **subject catalog** (Math, English: `name`, `code`, `description`).
- **`class_subjects`** — a **subject taught in a class by a teacher** (`classId` + `subjectId` + `teacherId`). The many-to-many junction class↔subject; `assessments`, `attendance_sessions`, and `timetable_periods` hang off it. (So `subjects` = "what"; `class_subjects` = "this offering".) - **`class_subjects`** — a **subject taught in a class by a teacher** (`classId` + `subjectId` + `teacherId`). The many-to-many junction class↔subject; `assessments`, `attendance_sessions`, and `timetable_periods` hang off it. (So `subjects` = "what"; `class_subjects` = "this offering".)
- **`classes`** — a class/group (`name`, `section`, `capacity`, `grade`, `homeroom_teacher``staff`, `academic_year`, `campus`). The grouping unit relating teachers (via `class_subjects`), students (via `class_enrollments`), and guardians (via their student). - **`classes`** — a class/group (`name`, `section`, `capacity`, `grade`, `homeroom_teacher``users`, `academic_year`, `campus`). The grouping unit relating teachers (via `class_subjects`), students (via `class_enrollments`), and guardians (via their student).
- **`class_enrollments`** — **student↔class membership** (`classId` + `studentId`, `enrolled_on`, `ended_on`, `status`). - **`class_enrollments`** — **student↔class membership** (`classId` + `studentId`, `enrolled_on`, `ended_on`, `status`).
### Assessments (header/detail pair) ### Assessments (header/detail pair)
@ -54,7 +54,7 @@ a coherent academic/SIS graph:
### Attendance (header/detail pair — student-level) ### Attendance (header/detail pair — student-level)
- **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by``staff`, `class`/`class_subject`). - **`attendance_sessions`** — one roll-call **event** for a class (`session_date`, `session_type`, `taken_by``users`, `class`/`class_subject`).
- **`attendance_records`** — a **student's status** in a session (`status` present/absent/late, `minutes_late`) per `studentId`. - **`attendance_records`** — a **student's status** in a session (`status` present/absent/late, `minutes_late`) per `studentId`.
- Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses the **aggregate** `campus_attendance_*` instead, and staff use `staff_attendance_records`. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision. - Two tables = one session → many student rows. **This is the per-student model and is currently unwired.** The working `/attendance` page uses the **aggregate** `campus_attendance_*` instead, and staff use `staff_attendance_records`. If per-student attendance is needed, wire these; do not fold student + staff + aggregate into one model without a deliberate decision.
@ -65,7 +65,7 @@ a coherent academic/SIS graph:
### People ### People
- **`staff`** — the **employment/HR profile** of a user (`employee_number`, `job_title`, `staff_type`, `hire_date`, `status`, `campus`), linked by `userId`. Distinct from `users` (the **account/identity**: login, email, role, password). One user ↔ one staff profile; students/guardians are users **without** a staff record. Already used by the staff-management and staff-attendance slices. - **`users`** — the account, identity, role, and scope. Do not add a separate employment profile table; employees, students, and guardians are all users with roles and scope columns. Staff classification is derived from `roles.name` plus the user's scope columns.
## Pruned — do NOT re-add ## Pruned — do NOT re-add

View File

@ -21,7 +21,7 @@ Types below are the SQL column types. A few Sequelize types are returned as JS `
## Domains ## Domains
- **Tenancy & Access:** `organizations`, `users`, `roles`, `permissions` - **Tenancy & Access:** `organizations`, `users`, `roles`, `permissions`
- **Campuses & People:** `campuses`, `staff` - **Campuses & People:** `campuses`, `users`
- **Academics:** `academic_years`, `grades`, `subjects`, `classes`, `class_enrollments`, `class_subjects`, `timetables`, `timetable_periods`, `assessments`, `assessment_results` - **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` - **Attendance:** `attendance_sessions`, `attendance_records`, `campus_attendance_config`, `campus_attendance_summaries`, `staff_attendance_records`
- **Communication:** `messages`, `message_recipients`, `communication_events` - **Communication:** `messages`, `message_recipients`, `communication_events`
@ -46,7 +46,7 @@ erDiagram
campuses ||--o{ attendance_sessions : "campus" campuses ||--o{ attendance_sessions : "campus"
classes ||--o{ attendance_sessions : "class" classes ||--o{ attendance_sessions : "class"
class_subjects ||--o{ attendance_sessions : "class_subject" class_subjects ||--o{ attendance_sessions : "class_subject"
staff ||--o{ attendance_sessions : "taken_by" users ||--o{ attendance_sessions : "taken_by"
users ||--o{ auth_refresh_tokens : "user" users ||--o{ auth_refresh_tokens : "user"
organizations ||--o{ auth_refresh_tokens : "organization" organizations ||--o{ auth_refresh_tokens : "organization"
organizations ||--o{ campus_attendance_config : "organization" organizations ||--o{ campus_attendance_config : "organization"
@ -59,12 +59,12 @@ erDiagram
organizations ||--o{ class_subjects : "organization" organizations ||--o{ class_subjects : "organization"
classes ||--o{ class_subjects : "class" classes ||--o{ class_subjects : "class"
subjects ||--o{ class_subjects : "subject" subjects ||--o{ class_subjects : "subject"
staff ||--o{ class_subjects : "teacher" users ||--o{ class_subjects : "teacher"
organizations ||--o{ classes : "organization" organizations ||--o{ classes : "organization"
campuses ||--o{ classes : "campus" campuses ||--o{ classes : "campus"
academic_years ||--o{ classes : "academic_year" academic_years ||--o{ classes : "academic_year"
grades ||--o{ classes : "grade" grades ||--o{ classes : "grade"
staff ||--o{ classes : "homeroom_teacher" users ||--o{ classes : "homeroom_teacher"
organizations ||--o{ communication_events : "organization" organizations ||--o{ communication_events : "organization"
campuses ||--o{ communication_events : "campus" campuses ||--o{ communication_events : "campus"
organizations ||--o{ frame_entries : "organization" organizations ||--o{ frame_entries : "organization"
@ -81,9 +81,6 @@ erDiagram
organizations ||--o{ safety_quiz_results : "organization" organizations ||--o{ safety_quiz_results : "organization"
campuses ||--o{ safety_quiz_results : "campus" campuses ||--o{ safety_quiz_results : "campus"
users ||--o{ safety_quiz_results : "user" users ||--o{ safety_quiz_results : "user"
organizations ||--o{ staff : "organization"
campuses ||--o{ staff : "campus"
users ||--o{ staff : "user"
organizations ||--o{ staff_attendance_records : "organization" organizations ||--o{ staff_attendance_records : "organization"
campuses ||--o{ staff_attendance_records : "campus" campuses ||--o{ staff_attendance_records : "campus"
users ||--o{ staff_attendance_records : "user" users ||--o{ staff_attendance_records : "user"
@ -136,7 +133,6 @@ _Relations:_
- **has many** `academic_years` as `academic_years_organization` (FK `organizationId`) - **has many** `academic_years` as `academic_years_organization` (FK `organizationId`)
- **has many** `grades` as `grades_organization` (FK `organizationId`) - **has many** `grades` as `grades_organization` (FK `organizationId`)
- **has many** `subjects` as `subjects_organization` (FK `organizationId`) - **has many** `subjects` as `subjects_organization` (FK `organizationId`)
- **has many** `staff` as `staff_organization` (FK `organizationId`)
- **has many** `classes` as `classes_organization` (FK `organizationId`) - **has many** `classes` as `classes_organization` (FK `organizationId`)
- **has many** `class_enrollments` as `class_enrollments_organization` (FK `organizationId`) - **has many** `class_enrollments` as `class_enrollments_organization` (FK `organizationId`)
- **has many** `class_subjects` as `class_subjects_organization` (FK `organizationId`) - **has many** `class_subjects` as `class_subjects_organization` (FK `organizationId`)
@ -169,7 +165,6 @@ Authentication identities. `email` is required (login + primary contact). Belong
| `passwordResetToken` | text | yes | — | | | `passwordResetToken` | text | yes | — | |
| `passwordResetTokenExpiresAt` | timestamptz | yes | — | | | `passwordResetTokenExpiresAt` | timestamptz | yes | — | |
| `provider` | text | yes | — | | | `provider` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `organizationId` | uuid | yes | — | FK | | `organizationId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit | | `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit |
@ -183,7 +178,6 @@ _Relations:_
- **many-to-many with** `permissions` as `custom_permissions` (FK `users_custom_permissionsId`) - **many-to-many with** `permissions` as `custom_permissions` (FK `users_custom_permissionsId`)
- **many-to-many with** `permissions` as `custom_permissions_filter` (FK `users_custom_permissionsId`) - **many-to-many with** `permissions` as `custom_permissions_filter` (FK `users_custom_permissionsId`)
- **has many** `staff` as `staff_user` (FK `userId`)
- **has many** `messages` as `messages_sent_by` (FK `sent_byId`) - **has many** `messages` as `messages_sent_by` (FK `sent_byId`)
- **belongs to** `roles` as `app_role` (FK `app_roleId`) - **belongs to** `roles` as `app_role` (FK `app_roleId`)
- **belongs to** `organizations` as `organizations` (FK `organizationId`) - **belongs to** `organizations` as `organizations` (FK `organizationId`)
@ -192,7 +186,7 @@ _Relations:_
#### `roles` #### `roles`
Named permission sets (RBAC), the 11 first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles). Named permission sets (RBAC), the first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles).
| Column | Type | Null | Default | Notes | | Column | Type | Null | Default | Notes |
|---|---|---|---|---| |---|---|---|---|---|
@ -262,45 +256,12 @@ A physical or online campus belonging to one organization. Parent of students, s
_Relations:_ _Relations:_
- **has many** `staff` as `staff_campus` (FK `campusId`)
- **has many** `classes` as `classes_campus` (FK `campusId`) - **has many** `classes` as `classes_campus` (FK `campusId`)
- **has many** `timetables` as `timetables_campus` (FK `campusId`) - **has many** `timetables` as `timetables_campus` (FK `campusId`)
- **has many** `attendance_sessions` as `attendance_sessions_campus` (FK `campusId`) - **has many** `attendance_sessions` as `attendance_sessions_campus` (FK `campusId`)
- **has many** `messages` as `messages_campus` (FK `campusId`) - **has many** `messages` as `messages_campus` (FK `campusId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `organizations` as `organization` (FK `organizationId`)
#### `staff`
Staff members, optionally linked to a `user` account; can be homeroom teacher, subject teacher, attendance taker, payment receiver.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `employee_number` | text | yes | — | |
| `job_title` | text | yes | — | |
| `staff_type` | enum | yes | — | |
| `hire_date` | timestamptz | yes | — | |
| `status` | enum | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `campusId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `userId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **has many** `classes` as `classes_homeroom_teacher` (FK `homeroom_teacherId`)
- **has many** `class_subjects` as `class_subjects_teacher` (FK `teacherId`)
- **has many** `attendance_sessions` as `attendance_sessions_taken_by` (FK `taken_byId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `user` (FK `userId`)
- **has many** `file` as `photo` (FK `belongsToId`)
### Academics ### Academics
#### `academic_years` #### `academic_years`
@ -407,7 +368,7 @@ _Relations:_
- **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) - **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`)
- **belongs to** `grades` as `grade` (FK `gradeId`) - **belongs to** `grades` as `grade` (FK `gradeId`)
- **belongs to** `staff` as `homeroom_teacher` (FK `homeroom_teacherId`) - **belongs to** `users` as `homeroom_teacher` (FK `homeroom_teacherId`)
#### `class_enrollments` #### `class_enrollments`
@ -461,7 +422,7 @@ _Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `classes` as `class` (FK `classId`) - **belongs to** `classes` as `class` (FK `classId`)
- **belongs to** `subjects` as `subject` (FK `subjectId`) - **belongs to** `subjects` as `subject` (FK `subjectId`)
- **belongs to** `staff` as `teacher` (FK `teacherId`) - **belongs to** `users` as `teacher` (FK `teacherId`)
#### `timetables` #### `timetables`
@ -577,7 +538,7 @@ _Relations:_
#### `attendance_sessions` #### `attendance_sessions`
An attendance session for a class/class_subject taken by a staff member. An attendance session for a class/class_subject taken by a user.
| Column | Type | Null | Default | Notes | | Column | Type | Null | Default | Notes |
|---|---|---|---|---| |---|---|---|---|---|
@ -604,7 +565,7 @@ _Relations:_
- **belongs to** `campuses` as `campus` (FK `campusId`) - **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `classes` as `class` (FK `classId`) - **belongs to** `classes` as `class` (FK `classId`)
- **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`) - **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`)
- **belongs to** `staff` as `taken_by` (FK `taken_byId`) - **belongs to** `users` as `taken_by` (FK `taken_byId`)
#### `attendance_records` #### `attendance_records`
@ -647,6 +608,8 @@ Product-module config for campus attendance.
| `deletedAt` | timestamptz | yes | — | audit | | `deletedAt` | timestamptz | yes | — | audit |
| `organizationId` | uuid | yes | — | FK | | `organizationId` | uuid | yes | — | FK |
| `campusId` | uuid | yes | — | FK | | `campusId` | uuid | yes | — | FK |
| `schoolId` | uuid | yes | — | exact-scope owner |
| `classId` | uuid | yes | — | exact-scope owner |
| `createdById` | uuid | yes | — | FK, audit | | `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit |
@ -783,13 +746,17 @@ Product-module communication events (meetings, drills, events, deadlines).
| `title` | text | no | — | | | `title` | text | no | — | |
| `event_date` | date | no | — | | | `event_date` | date | no | — | |
| `event_type` | text | no | — | | | `event_type` | text | no | — | |
| `targetLevel` | text | no | campus | exact alert audience: system/all/organization/school/campus |
| `roles` | jsonb | no | Array | | | `roles` | jsonb | no | Array | |
| `importHash` | varchar | yes | — | unique, audit | | `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit | | `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit | | `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit | | `deletedAt` | timestamptz | yes | — | audit |
| `organizationId` | uuid | yes | — | FK | | `organizationId` | uuid | yes | — | FK |
| `schoolId` | uuid | yes | — | FK |
| `campusId` | uuid | yes | — | FK | | `campusId` | uuid | yes | — | FK |
| `classId` | uuid | yes | — | reserved; class alerts are not created |
| `canceledEventId` | uuid | yes | — | original alert id when this row is a cancellation notification |
| `createdById` | uuid | yes | — | FK, audit | | `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit | | `updatedById` | uuid | yes | — | FK, audit |
@ -807,9 +774,13 @@ Product content catalog.
| Column | Type | Null | Default | Notes | | Column | Type | Null | Default | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK | | `id` | uuid | no | UUIDV4 | PK |
| `content_type` | text | no | — | unique | | `content_type` | text | no | — | content key; tenant-scoped types can have one row per owner |
| `payload` | jsonb | no | — | | | `payload` | jsonb | no | — | |
| `active` | boolean | no | true | | | `active` | boolean | no | true | |
| `organizationId` | uuid | yes | — | exact tenant owner for org/school/campus-scoped content |
| `schoolId` | uuid | yes | — | exact tenant owner for school-scoped content |
| `campusId` | uuid | yes | — | exact tenant owner for campus-scoped content |
| `classId` | uuid | yes | — | reserved exact tenant owner; class roles currently read campus content |
| `importHash` | varchar | yes | — | unique, audit | | `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit | | `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit | | `updatedAt` | timestamptz | yes | — | audit |
@ -847,7 +818,7 @@ _Relations:_
#### `user_progress` #### `user_progress`
Per-user progress (sign learning, zone check-ins). Per-user progress (sign learning, zone check-ins, classroom strategy favorites).
| Column | Type | Null | Default | Notes | | Column | Type | Null | Default | Notes |
|---|---|---|---|---| |---|---|---|---|---|
@ -1103,4 +1074,3 @@ _Relations:_
- **belongs to** `users` as `user` (FK `userId`) - **belongs to** `users` as `user` (FK `userId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`) - **belongs to** `organizations` as `organization` (FK `organizationId`)

View File

@ -19,10 +19,9 @@ contract the reference frontend used (preserved here so it isn't lost):
download URL is `${API_BASE_URL}/file/download?privateUrl=<privateUrl>` (works download URL is `${API_BASE_URL}/file/download?privateUrl=<privateUrl>` (works
for both the local-disk dev backend and the GCloud prod backend). for both the local-disk dev backend and the GCloud prod backend).
**Open blocker:** `assertCanDownloadFile` denies any `privateUrl` with no tracked Downloads are JWT-only by customer decision. The standalone `/file/upload/:table/:field`
`file` row, but the standalone `/file/upload/:table/:field` path does not create path can therefore serve uploaded logos/avatars/files even when it does not create a
one — so a non-global user would 403 on download until the upload flow also tracked `file` row.
records a `file` row (or the path is exempted). See `audio-files.md`.
## Slice Files (by layer) ## Slice Files (by layer)
@ -31,12 +30,9 @@ records a `file` row (or the path is exempted). See `audio-files.md`.
calls `assertCanDownloadFile` before serving. calls `assertCanDownloadFile` before serving.
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`, - Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
`downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus `downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus
`src/services/file-access.ts` (`assertCanDownloadFile`) for the per-file authorization. Both `src/services/file-access.ts` (`assertCanDownloadFile`) for download authorization. Both
upload and download require JWT; local handlers reject path traversal. Download enforces a upload and download require JWT; local handlers reject path traversal. Download no longer
per-file tenant/ownership check: the file's owning organization (resolved from its `privateUrl` enforces per-file tenant ownership.
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`, - Repository (DAL): `src/db/api/file.ts` (`FileDBApi``replaceRelationFiles`, `_addFiles`,
`_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports `_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports
`@/services/file` to call `deleteGCloud` (see Behavior / Notes). `@/services/file` to call `deleteGCloud` (see Behavior / Notes).
@ -48,12 +44,9 @@ records a `file` row (or the path is exempted). See `audio-files.md`.
## API ## API
- `GET /api/file/download?privateUrl=<path>` -> downloads the file. **Requires JWT authentication** - `GET /api/file/download?privateUrl=<path>` -> downloads the file. **Requires JWT authentication**
(`passport.authenticate('jwt')`) **and per-file ownership**: `assertCanDownloadFile` resolves the (`passport.authenticate('jwt')`). `assertCanDownloadFile` verifies that a current user exists,
file's owning organization (via `FileDBApi.findOwnerOrganizationIdByPrivateUrl`, which reads the then the controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or
uploader `createdById`) and returns `403` (`ForbiddenError`) unless the requester has global `NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`.
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 - Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path
traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download` traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download`
from `config.uploadDir`. from `config.uploadDir`.
@ -76,8 +69,8 @@ records a `file` row (or the path is exempted). See `audio-files.md`.
rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied
(`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not (`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not
exercised from this endpoint. exercised from this endpoint.
- Download has no authentication middleware and performs no ownership check; access is governed - Download requires a valid JWT and performs no per-file ownership check; access is governed by
solely by knowing the `privateUrl`. authentication plus knowledge of the `privateUrl`.
## Tenant Scope ## Tenant Scope
@ -109,8 +102,8 @@ otherwise `ValidationError('iam.errors.fileNameRequired')` is raised.
(`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for (`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for
`services/file.ts` and `services/auth.ts`. `services/file.ts` and `services/auth.ts`.
- `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy - `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy
files), which is a DAL→BLL dependency. The same import-boundaries test caps DAL→BLL violations at files), which is a DAL→BLL dependency. The same import-boundaries test keeps this as an exact
one, and this file is the allowed one. allowlisted exception; any additional DAL→BLL import fails the architecture test.
- `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not - `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not
present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows
for inputs marked `new`. for inputs marked `new`.
@ -119,7 +112,7 @@ otherwise `ValidationError('iam.errors.fileNameRequired')` is raised.
No dedicated `file` unit/e2e test exists. The architecture test No dedicated `file` unit/e2e test exists. The architecture test
`src/shared/architecture/import-boundaries.test.ts` references this slice by name in its `src/shared/architecture/import-boundaries.test.ts` references this slice by name in its
BLL→HTTP and DAL→BLL debt-ceiling assertions. exact BLL→HTTP and DAL→BLL exception allowlists.
## Related ## Related

View File

@ -14,8 +14,8 @@ source of truth for persisted FRAME data; the frontend never substitutes static
`db/api/frame_entries.ts`). `db/api/frame_entries.ts`).
- Model: `src/db/models/frame_entries.ts`. - Model: `src/db/models/frame_entries.ts`.
- Shared used: `db/with-transaction.ts` (`withTransaction`), `services/shared/access.ts` - Shared used: `db/with-transaction.ts` (`withTransaction`), `services/shared/access.ts`
(`getOrganizationIdOrGlobal`, `hasRoleAccess`), `shared/constants/pagination.ts` (`resolvePagination`), (`getOrganizationIdOrGlobal`, `hasFeaturePermission`), `shared/constants/pagination.ts` (`resolvePagination`),
`shared/constants/frame.ts` (`FRAME_EDITOR_ROLE_NAMES`), `shared/errors/*` `shared/errors/*`
(`ForbiddenError`, `ValidationError`). (`ForbiddenError`, `ValidationError`).
## API ## API
@ -33,18 +33,16 @@ Request body for create/update is wrapped as `{ data: <FrameEntryInput> }`.
- Read: any authenticated user in the organization, or any user with `globalAccess` (sees all - Read: any authenticated user in the organization, or any user with `globalAccess` (sees all
organizations). organizations).
- Edit (create/update): restricted to roles in `FRAME_EDITOR_ROLE_NAMES` (director/superintendent - Edit (create/update/delete): requires `MANAGE_FRAME`. Role-seeded permissions
capabilities) — `super_admin`, `system_admin`, `owner`, `superintendent`, are only the baseline grants. Per-user `custom_permissions` can grant it and
`director`. Enforced by `assertCanEdit` via `hasRoleAccess`; a non-editor gets `custom_permissions_filter` can remove it for non-global users.
`ForbiddenError`. Frontend may hide editing controls, but the backend check is authoritative.
## Tenant Scope ## Tenant Scope
- Organization is resolved via `getOrganizationIdOrGlobal`: users with `globalAccess` bypass the - Organization is resolved via `getOrganizationIdOrGlobal`: users with `globalAccess` bypass the
org filter and see/create entries across all organizations; regular users are bound to their org filter and see/create entries across all organizations; regular users are bound to their
organization. organization.
- `campusId` is optional; when omitted it defaults to the current staff profile's campus - `campusId` is optional; when omitted it defaults to the current user's direct campus scope when available, else `null`.
(`currentUser.staff_user[0].campusId`) when available, else `null`.
## Data Contract ## Data Contract
@ -76,4 +74,4 @@ free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null
## Related ## Related
- Frontend: `frontend/docs/frame-integration.md`. - Frontend: `frontend/docs/frame-integration.md`.
- Related slices: `user-progress.md` (dashboard zone check-ins), `staff` (campus resolution). - Related slices: `user-progress.md` (dashboard zone check-ins), `users` (campus resolution).

View File

@ -2,7 +2,7 @@
## Start Here ## Start Here
- Repository working rules: [`../../CLAUDE.md`](../../CLAUDE.md) - Repository working rules: [`../../AGENTS.md`](../../AGENTS.md)
- Backend architecture: [`backend-architecture.md`](backend-architecture.md) - Backend architecture: [`backend-architecture.md`](backend-architecture.md)
- Database schema: [`database-schema.md`](database-schema.md) - Database schema: [`database-schema.md`](database-schema.md)
- Error handling: [`error-handling.md`](error-handling.md) - Error handling: [`error-handling.md`](error-handling.md)
@ -30,7 +30,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
- [`auth-profile.md`](auth-profile.md): sign-in, profile, `GET /api/auth/me`, OAuth, permission model. - [`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. - [`cookie-auth.md`](cookie-auth.md): HttpOnly cookie sessions and refresh rotation.
- [`permissions.md`](permissions.md): the `${METHOD}_${ENTITY}` permission catalog and enforcement. - [`permissions.md`](permissions.md): the `${METHOD}_${ENTITY}` permission catalog and enforcement.
- [`roles.md`](roles.md): the 11 first-class roles (scope, globalAccess) and role<->permission linkage. - [`roles.md`](roles.md): first-class roles (scope, globalAccess) and role<->permission linkage.
- [`users.md`](users.md): users entity, invitations, role policy, provisioning, and CSV bulk import. - [`users.md`](users.md): users entity, invitations, role policy, provisioning, and CSV bulk import.
## Product Feature Slices ## Product Feature Slices
@ -54,7 +54,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
One document per entity (assembled from the shared CRUD factories; identical 9-endpoint surface — One document per entity (assembled from the shared CRUD factories; identical 9-endpoint surface —
see [`shared-crud-factories.md`](shared-crud-factories.md)). see [`shared-crud-factories.md`](shared-crud-factories.md)).
- People: [`staff.md`](staff.md), [`organizations.md`](organizations.md). - People: [`users.md`](users.md), [`organizations.md`](organizations.md).
- Academics: [`classes.md`](classes.md), [`subjects.md`](subjects.md), - Academics: [`classes.md`](classes.md), [`subjects.md`](subjects.md),
[`class_subjects.md`](class_subjects.md), [`class_enrollments.md`](class_enrollments.md), [`class_subjects.md`](class_subjects.md), [`class_enrollments.md`](class_enrollments.md),
[`academic_years.md`](academic_years.md), [`assessments.md`](assessments.md), [`academic_years.md`](academic_years.md), [`assessments.md`](assessments.md),

View File

@ -24,11 +24,14 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
`20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added `20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added
nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts` nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts`
(the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date). (the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date).
- Seeders: `src/db/seeders/*.ts``admin-user` (the 10 per-role RBAC fixture users), - Seeders: `src/db/seeders/*.ts``admin-user` (the system users, the primary
`user-roles` (the 11 first-class roles, the permission catalog incl. product-feature tenant's per-role users, and the secondary tenant's per-role users from
`shared/constants/seed-fixtures.ts`),
`user-roles` (the first-class roles, the permission catalog incl. product-feature
permissions, the role->permission matrix, role assignment by user id), `product-campuses`, permissions, the role->permission matrix, role assignment by user id), `product-campuses`,
`content-catalog` (+ payloads under `seeders/content-catalog-data/`), `rbac-fixtures` `content-catalog` (+ payloads under `seeders/content-catalog-data/`), `rbac-fixtures`
(the company, campus->org ownership, per-user org/campus links, staff profiles), and (the two companies, school/campus ownership, per-user org/school/campus links,
and user employment fields), `class-fixtures` (one class, enrollment, and guardian link per tenant), and
`20260611050000-policy-documents-seed.ts` (3 safety protocols + 4 handbook policies). Shared `20260611050000-policy-documents-seed.ts` (3 safety protocols + 4 handbook policies). Shared
fixture definitions live in `src/shared/constants/seed-fixtures.ts`. fixture definitions live in `src/shared/constants/seed-fixtures.ts`.
@ -65,7 +68,10 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
## Tests ## Tests
None yet. - `src/shared/constants/seed-fixtures.test.ts` covers primary/secondary tenant
user topology and credential uniqueness.
- `src/db/seeders/user-roles.test.ts` covers the seeded product-permission
contract for parent communication and registrar report/audit grants.
## Related ## Related

View File

@ -99,4 +99,4 @@ None yet.
## Related ## Related
- Generic-CRUD contract: `backend-architecture.md`; tenant scoping: `permissions.md`. Every - Generic-CRUD contract: `backend-architecture.md`; tenant scoping: `permissions.md`. Every
per-organization slice references this table via `organizationId` (e.g. `staff`, `campuses`). per-organization slice references this table via `organizationId` (e.g. `users`, `campuses`).

View File

@ -64,9 +64,13 @@ then delegates to `checkPermissions(permissionName)`.
1. Self-access bypass: read-only — a `GET` whose `currentUser.id === req.params.id` (the 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`). `req.body.id` bypass was removed; profile self-edits go through `/api/auth/profile`).
2. Custom (per-user) permissions: the current user's `custom_permissions` contains a record whose 2. Global-access bypass: the current user's `app_role.globalAccess` is true, except for personal
workflow permissions listed in `GLOBAL_BYPASS_EXCLUDED_PERMISSIONS`.
3. Custom permission filter: a non-global user's `custom_permissions_filter` can deny a permission
that would otherwise come from the role grant.
4. Custom (per-user) permissions: the current user's `custom_permissions` contains a record whose
`name` equals the required permission. `name` equals the required permission.
3. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise 5. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise
the `guest` role (`ROLE_NAMES.GUEST`), which is fetched once at module load via 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 `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 synchronously as a fallback. `resolveRolePermissions` reads the role's permission names from an
@ -82,16 +86,17 @@ Error via `next(new Error(...))`.
Besides the `${METHOD}_${ENTITY}` CRUD permissions, the catalog includes product-feature Besides the `${METHOD}_${ENTITY}` CRUD permissions, the catalog includes product-feature
permissions defined once in `shared/constants/product-permissions.ts`: a `READ_<MODULE>` per permissions defined once in `shared/constants/product-permissions.ts`: a `READ_<MODULE>` per
product page and the three action permissions `FILL_ATTENDANCE` / `TAKE_QUIZ` / `ACK_READ_RECEIPT`. product page plus action/report/manage permissions such as `FILL_ATTENDANCE`, `TAKE_QUIZ`,
The role seeder grants them per role (full-access roles get all; campus staff get their page set; `ACK_READ_RECEIPT`, `ACK_POLICY`, `ZONE_CHECKIN`, `MANAGE_*`, and report reads.
external roles get the external pages). The feature routes enforce them with the **same** The role seeder grants baseline permission sets; `custom_permissions` and
`custom_permissions_filter` then adjust individual users. The feature routes enforce them with the **same**
`checkPermissions(name)` middleware: page reads and the special actions call it directly `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/...` (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 `FILL_ATTENDANCE`, `POST /api/safety_quiz_results``TAKE_QUIZ`). Because that middleware honors
`custom_permissions` (step 2 above), a director can extend a single user's feature access by `custom_permissions` (step 4 above), a manager can extend a single user's feature access by
granting one of these names. Manager-only writes (FRAME/walkthrough/communications/content-catalog 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 editing, audio files, and staff/attendance reports) use dedicated `MANAGE_*` or report-read
`MANAGE_*` permissions are introduced. permissions rather than role-name guards.
## Tenant Scope ## Tenant Scope

View File

@ -5,7 +5,7 @@
`personality_quiz_results` stores each authenticated tenant user's current personality quiz `personality_quiz_results` stores each authenticated tenant user's current personality quiz
result (one row per user per organization) and exposes an aggregate distribution of personality result (one row per user per organization) and exposes an aggregate distribution of personality
types for leadership reporting. The backend owns tenant scope, user ownership, the saved types for leadership reporting. The backend owns tenant scope, user ownership, the saved
personality type, and the answer snapshot. It does not write to staff profile records. personality type, and the answer snapshot. It does not write to user employment fields.
## Slice Files (by layer) ## Slice Files (by layer)
@ -18,8 +18,8 @@ personality type, and the answer snapshot. It does not write to staff profile re
separate `db/api/personality_quiz_results.ts`). separate `db/api/personality_quiz_results.ts`).
- Model: `src/db/models/personality_quiz_results.ts`. - Model: `src/db/models/personality_quiz_results.ts`.
- Shared used: `db/with-transaction.ts` (`withTransaction`); `services/shared/access.ts` - Shared used: `db/with-transaction.ts` (`withTransaction`); `services/shared/access.ts`
(`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasRoleAccess`); (`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasFeaturePermission`);
`shared/constants/personality.ts` (`PERSONALITY_REPORT_ROLE_NAMES`); `shared/errors/*` `shared/errors/*`
(`ForbiddenError`, `ValidationError`). (`ForbiddenError`, `ValidationError`).
## API ## API
@ -30,7 +30,8 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
(most recently updated), or `null` if none exists. (most recently updated), or `null` if none exists.
- `PUT /api/personality_quiz_results/me` -> `200`. Request body wrapped as - `PUT /api/personality_quiz_results/me` -> `200`. Request body wrapped as
`{ data: { personality_type, quiz_answers } }`. Creates or updates the current user's result and `{ data: { personality_type, quiz_answers } }`. Creates or updates the current user's result and
returns the saved DTO. returns the saved DTO. If the caller is a parent-scope user acting through a drilled child scope,
the request is accepted as a no-op and returns the caller's currently saved result (or `null`).
- `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns - `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns
`{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report `{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report
roles. roles.
@ -40,18 +41,24 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
- `getCurrentUserResult` / `upsertCurrentUserResult`: any authenticated tenant user - `getCurrentUserResult` / `upsertCurrentUserResult`: any authenticated tenant user
(`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by (`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by
`userId`). `userId`).
- `distribution`: restricted to `PERSONALITY_REPORT_ROLE_NAMES` (`super_admin`, - `upsertCurrentUserResult` persists only when the active scope is the user's own scope. Parent
`system_admin`, `owner`, `superintendent`, `director`) or any role with users drilled into a child school/campus/classroom can complete the UI flow there, but the backend
`globalAccess`, enforced by `hasRoleAccess`; otherwise `ForbiddenError`. The distribution does not create or update reportable quiz rows for that child scope.
response contains only `type` and `count` per group — no individual names or answers. - `distribution`: restricted to `READ_PERSONALITY_REPORTS`; otherwise
`ForbiddenError`. Role-seeded permissions are only the baseline grants. The distribution response contains
only `type` and `count` per group — no individual names or answers.
`custom_permissions` can grant the report permission and
`custom_permissions_filter` can remove it for non-global users.
## Tenant Scope ## Tenant Scope
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org - Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
filter and can see their results across organizations; regular users are bound to their org. filter and can see their results across organizations; regular users are bound to their org.
- On upsert, `campusId` is set from `getCampusId` (the current staff profile's campus, else the - On upsert, `campusId` is set from `getCampusId` (the current user's direct campus, else the
user's `campusId`, else `null`); `userId`, `createdById`, `updatedById` come from the current user's `campusId`, else `null`); `userId`, `createdById`, `updatedById` come from the current
user. user.
- Drilled child scopes are not treated as the user's own scope for personal saves, even though reads
and reports use the active scope for visibility.
- `distribution` filters by organization via `getOrganizationIdOrGlobal` (global users see all - `distribution` filters by organization via `getOrganizationIdOrGlobal` (global users see all
orgs) and, when a `campusId` query value is provided, additionally by that campus. orgs) and, when a `campusId` query value is provided, additionally by that campus.
@ -72,7 +79,9 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
## Behavior / Notes ## Behavior / Notes
- `upsertCurrentUserResult` runs inside `withTransaction`: it looks up the existing row by - `upsertCurrentUserResult` first checks whether the caller is acting in their own scope. If not,
it skips persistence and returns the current saved result. Otherwise it runs inside
`withTransaction`: it looks up the existing row by
`organizationId` + `userId` and updates it, otherwise creates a new one. `organizationId` + `userId` and updates it, otherwise creates a new one.
- `getCurrentUserResult` orders by `updatedAt` desc and returns the first match. - `getCurrentUserResult` orders by `updatedAt` desc and returns the first match.
- `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc; - `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc;
@ -80,7 +89,8 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
## Tests ## Tests
None yet (no `personality_quiz_results` unit/e2e test in `src/`). - `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
scopes do not create or update personality quiz rows.
## Related ## Related

View File

@ -6,8 +6,8 @@ Workstream 11 — persistence of staff acknowledgment of policy/safety documents
Campus staff must acknowledge two categories of documents — **Safety Protocols** Campus staff must acknowledge two categories of documents — **Safety Protocols**
(official/government) and **Handbook & Policies** (internal). `director` and (official/government) and **Handbook & Policies** (internal). `director` and
`office_manager` author the documents; all four campus staff roles (`director`, `office_manager` author the documents; users with explicit `ACK_POLICY`
`office_manager`, `teacher`, `support_staff`) acknowledge them. Acknowledgment is acknowledge them. Acknowledgment is
**per document version**: editing a document bumps its `version`, which requires **per document version**: editing a document bumps its `version`, which requires
re-acknowledgment. re-acknowledgment.
@ -18,9 +18,8 @@ entity it replaced has been removed):
- **Handbook & Policies** (`business/policies`) lists `policy_documents` of - **Handbook & Policies** (`business/policies`) lists `policy_documents` of
`category = handbook_policy`, mapping the handbook's sub-category to/from `tag` `category = handbook_policy`, mapping the handbook's sub-category to/from `tag`
(`toPolicyViewModel` / `toPolicyDocumentMutationDto`). Management is gated to (`toPolicyViewModel` / `toPolicyDocumentMutationDto`). Management affordances
owner/superintendent/director/office_manager (`canManagePolicies`, mirroring the are gated by effective policy-document permissions. Acknowledgment is **persisted** via `policy_acknowledgments`
backend grant). Acknowledgment is **persisted** via `policy_acknowledgments`
(`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former (`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former
local-state set. local-state set.
- **Safety Protocols** (`business/safety-protocols`) consumes - **Safety Protocols** (`business/safety-protocols`) consumes
@ -35,9 +34,12 @@ entity it replaced has been removed):
**dynamic** `steps` + `autismConsiderations` rows that add/remove **dynamic** `steps` + `autismConsiderations` rows that add/remove
independently, so each protocol carries its own count independently, so each protocol carries its own count
(`useSafetyProtocolsModule` + `SafetyProtocolForm` / (`useSafetyProtocolsModule` + `SafetyProtocolForm` /
`SafetyDynamicListEditor`; gated by `canManageSafetyProtocols`, which reuses `SafetyDynamicListEditor`; gated by effective policy-document permissions).
the policy grant). Title/body/steps/considerations changes bump `version` and Title/body/steps/considerations changes bump `version` and
require re-acknowledgment. require re-acknowledgment.
- **Acknowledgments** (`business/policies`, `pages/modules/AcknowledgmentsPage`)
renders the manager report from `GET /api/policy_acknowledgments/report`.
The Director Dashboard also shows the report summary as an overview card.
## Entities ## Entities
@ -68,8 +70,12 @@ entity it replaced has been removed):
`checkCrudPermissions('policy_documents')` (`${METHOD}_POLICY_DOCUMENTS`). `checkCrudPermissions('policy_documents')` (`${METHOD}_POLICY_DOCUMENTS`).
- `GET /api/policy_acknowledgments` (the caller's own acknowledgments) and - `GET /api/policy_acknowledgments` (the caller's own acknowledgments) and
`POST /api/policy_acknowledgments` (`{ data: { policyDocumentId } }` `POST /api/policy_acknowledgments` (`{ data: { policyDocumentId } }`
acknowledges the document's **current** version) — both guarded by acknowledges the document's **current** version, or returns `null` as a no-op when the caller is
acting through a drilled child scope that is not their own scope) — both guarded by
`checkPermissions('ACK_POLICY')`. `checkPermissions('ACK_POLICY')`.
- `GET /api/policy_acknowledgments/report` — manager-facing acknowledgment
report for the current tenant scope. Returns summary totals, per-document
completion rows, and per-staff document statuses.
## Authorization ## Authorization
@ -79,13 +85,26 @@ entity it replaced has been removed):
- `CREATE/UPDATE/DELETE_POLICY_DOCUMENTS``director` (full access) and - `CREATE/UPDATE/DELETE_POLICY_DOCUMENTS``director` (full access) and
`office_manager` (explicit grant in the role seeder). `teacher`/`support_staff` `office_manager` (explicit grant in the role seeder). `teacher`/`support_staff`
are read-only. are read-only.
- `ACK_POLICY` — the four campus roles (a product-feature action permission; - `ACK_POLICY` — seeded for `director`, `office_manager`, `teacher`, and
extendable per user via `custom_permissions`). `support_staff`. It is a personal workflow permission, is not implied by
`globalAccess`, and can be extended or removed per user via effective
permissions. Acknowledgments persist only when the active scope is the user's own scope; parent
users drilled into a child school/campus/classroom do not see personal acknowledgment badges or
acknowledgment actions there, and the backend no-ops any attempted write so no reportable rows are
created for the child scope.
Tenant/campus scoping is applied in the data layer (`tenantWhere` / Tenant/campus scoping is applied in the data layer (`tenantWhere` /
`findOwnedByPk`); acknowledgment reads are additionally restricted to the `findOwnedByPk`); acknowledgment reads are additionally restricted to the
caller's own `userId`. A manager-facing acknowledgment-status report (audience caller's own `userId`. The manager report is scoped to the current tenant:
TBD) is a deferred refinement. organization for owner/superintendent, school for principal/registrar, campus
for director, and the active drilled scope for platform admins. Report access
requires `READ_POLICY_ACKNOWLEDGMENT_REPORTS`; owner, superintendent,
principal, registrar, and director receive it through seeded baseline permissions.
super_admin/system_admin can read it only while drilled into a tenant.
`custom_permissions` can grant the report permission to tenant users and
`custom_permissions_filter` can remove it. The report population is active
staff accounts in the current scope holding one of
director/office_manager/teacher/support_staff roles.
## Tests ## Tests
@ -93,6 +112,8 @@ TBD) is a deferred refinement.
`users.test.ts`, `npm test`): the pure domain rules — `users.test.ts`, `npm test`): the pure domain rules —
`isPolicyDocumentCategory` validation, the `nextPolicyDocumentVersion` `isPolicyDocumentCategory` validation, the `nextPolicyDocumentVersion`
re-acknowledgment bump, and `formatPersonName` (author rendering). re-acknowledgment bump, and `formatPersonName` (author rendering).
- **Backend service** (`backend/src/services/policy_acknowledgments.test.ts`):
acknowledgment listing plus the drilled-child no-op rule for parent users.
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook; - **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps + tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
autism considerations) and `business/safety-protocols/selectors.test.ts` autism considerations) and `business/safety-protocols/selectors.test.ts`
@ -105,7 +126,4 @@ TBD) is a deferred refinement.
## Open / deferred ## Open / deferred
- Acknowledgment-status reporting for managers (who-acknowledged-what) — pending None.
the report-audience decision.
- The acknowledgment + document-management **UI** is design-gated (see
`docs/backlog.md`).

View File

@ -106,10 +106,11 @@ is `createdAt desc`.
`name ASC` and selects only `id`/`name`. `name ASC` and selects only `id`/`name`.
- Note: `RolesFilter` accepts an `active` flag and `findAll` filters on an `active` column the - 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). `roles` model does not declare; it is currently inert (kept for source accuracy).
- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the 11 first-class roles from - **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the first-class roles from
`ROLE_DEFINITIONS` (`shared/constants/roles.ts`), each with its `scope`. `globalAccess: true` is set `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 for the two system-scope roles (`super_admin`, `system_admin`) so their tenant reach is platform-wide.
per-permission checks (`check-permissions.ts`) and the `organizationId` filter. Org/campus roles `system_admin` still resolves permissions from explicit role rows like the tenant roles; only
`super_admin` bypasses the standard per-permission checks (`check-permissions.ts`). Org/campus roles
(`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained (`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained
to their tenant/campus by scoping; `student`, `guardian`, and the unauthenticated-fallback `guest` 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 have no entity-CRUD permissions. The seeder also assigns roles to the seeded users and writes the
@ -118,7 +119,10 @@ is `createdAt desc`.
## Tests ## Tests
None yet. - `src/db/seeders/user-roles.test.ts` covers role-seeder product permission
grants that are part of the role contract.
- `src/services/shared/role-policy.test.ts` covers target-role management
hierarchy rules.
## Related ## Related

View File

@ -17,9 +17,8 @@ role snapshot, and persistence. Each submission is an append (create) — there
- Model: `src/db/models/safety_quiz_results.ts`. - Model: `src/db/models/safety_quiz_results.ts`.
- Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts` - Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts`
(`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`, (`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`,
`assertAuthenticatedTenantUser`, `hasRoleAccess`, `getDisplayName`); `shared/constants/roles.ts` `assertAuthenticatedTenantUser`, `hasFeaturePermission`, `getDisplayName`);
(`ROLE_NAMES`); `shared/constants/safety-quiz.ts` `shared/constants/roles.ts` (`ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`).
(`SAFETY_QUIZ_REPORT_ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`).
## API ## API
@ -29,17 +28,22 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
`limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user `limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user
(see Access Rules), ordered by `completed_at` desc. (see Access Rules), ordered by `completed_at` desc.
- `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: <SafetyQuizInput> }`. - `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: <SafetyQuizInput> }`.
Returns the created result DTO. Returns the created result DTO. If the caller is a parent-scope user acting through a drilled
child scope, the request is accepted as a no-op and returns `null`.
## Access Rules ## Access Rules
- All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`). - All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`).
- `create`: a staff user creates a result for themselves; ownership fields are filled from the - `create`: a staff user creates a result for themselves; ownership fields are filled from the
authenticated user. authenticated user.
- `list`: users holding a role in `SAFETY_QUIZ_REPORT_ROLE_NAMES` (`super_admin`, - `create` persists only when the active scope is the user's own scope. Parent users drilled into a
`system_admin`, `owner`, `superintendent`, `director`) or any role with child school/campus/classroom can complete the quiz there, but the backend does not create
`globalAccess` (via `hasRoleAccess`) see all org-level results; everyone else sees only their own reportable quiz rows for that child scope.
rows (filtered by `userId`). - `list`: users with `READ_SAFETY_QUIZ_REPORTS` see scope-filtered results;
everyone else sees only their own rows (filtered by `userId`).
Role-seeded permissions are only the baseline grants. `custom_permissions` can
grant the report permission and
`custom_permissions_filter` can remove it for non-global users.
## Tenant Scope ## Tenant Scope
@ -47,6 +51,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
filter and see results across all organizations; regular users are bound to their organization. filter and see results across all organizations; regular users are bound to their organization.
- On create, `campusId` is set from `getCampusId`; `userId`, `createdById`, `updatedById` come from - On create, `campusId` is set from `getCampusId`; `userId`, `createdById`, `updatedById` come from
the current user. the current user.
- Drilled child scopes are not treated as the user's own scope for personal saves, even though list
visibility can use the active scope for reports.
## Data Contract ## Data Contract
@ -69,12 +75,15 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
## Behavior / Notes ## Behavior / Notes
- `create` runs inside `withTransaction`; trimmed string fields are persisted. - `create` first checks whether the caller is acting in their own scope. If not, it skips
persistence and returns `null`. Otherwise it runs inside `withTransaction`; trimmed string fields
are persisted.
- `list` is paginated with shared defaults (`resolvePagination`). - `list` is paginated with shared defaults (`resolvePagination`).
## Tests ## Tests
None yet (no `safety_quiz_results` unit/e2e test in `src/`). - `src/services/personal_scope_results.test.ts` verifies that parent users drilled into child
scopes do not create safety quiz result rows.
## Related ## Related

View File

@ -51,7 +51,7 @@ The searched tables and columns are fixed in the service:
- Text columns (`TABLE_COLUMNS`): `users` (firstName, lastName, phoneNumber, email); - Text columns (`TABLE_COLUMNS`): `users` (firstName, lastName, phoneNumber, email);
`organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name); `organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name);
`grades` (name, code, description); `subjects` (name, code, description); `grades` (name, code, description); `subjects` (name, code, description);
`staff` (employee_number, job_title); `classes` (name, section); `timetables` `users` (firstName, lastName, phoneNumber, email); `classes` (name, section); `timetables`
(name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks); (name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks);
`assessments` (name, instructions); `assessment_results` (remarks); `assessments` (name, instructions); `assessment_results` (remarks);
`messages` (subject, body); `message_recipients` (recipient_label, destination). `messages` (subject, body); `message_recipients` (recipient_label, destination).

View File

@ -114,17 +114,18 @@ Generic over `Model`; cover the methods that are byte-identical across entities,
### Access helpers (`src/services/shared/access.ts`) ### Access helpers (`src/services/shared/access.ts`)
- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleName(currentUser?)` - `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleScope(currentUser?)`
— resolve scope/role from the current user. — resolve tenant/scope from the current user.
- `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`. - `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`.
- `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id - `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id
or throw `ForbiddenError`. or throw `ForbiddenError`.
- `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user - `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user
has both an id and an organization. has both an id and an organization.
- `hasRoleAccess(currentUser, roleNames)``true` for `globalAccess` users or those - `hasFeaturePermission(currentUser, permission)` — resolves effective
holding one of `roleNames`. permissions, including `custom_permissions`, `custom_permissions_filter`, and
- `campusScope(currentUser, tenantWideRoleNames)` — returns `{}` for tenant-wide/global the `globalAccess` exclusions for personal workflow permissions.
users, else `{ campusId }` restricting to the user's campus. - `scopeDimensionWhere(currentUser, model)` — derives school/campus/class
constraints from the active scope.
### Validation helpers (`src/services/shared/validate.ts`) ### Validation helpers (`src/services/shared/validate.ts`)

View File

@ -2,27 +2,27 @@
## Purpose ## Purpose
`staff_attendance_records` stores staff-level attendance entries per organization. This slice is a `staff_attendance_records` stores staff-level attendance entries per organization. This slice exposes
read-only reporting surface: it exposes a filtered record list and an aggregated summary used by the a filtered record list, an aggregated summary used by the attendance snapshot, and a scoped upsert
attendance snapshot and the director dashboard. It does not write, import, or generate records. endpoint for manual office/staff attendance entry.
This is distinct from the student-level attendance models (`attendance_sessions`, This is distinct from the student-level attendance models (`attendance_sessions`,
`attendance_records`) and from campus daily aggregates (`campus_attendance_summaries`). `attendance_records`) and from campus daily aggregates (`campus_attendance_summaries`).
## Slice Files (by layer) ## Slice Files (by layer)
- Route: `src/routes/staff_attendance.ts` (thin wiring; `GET /records`, `GET /summary`). - Route: `src/routes/staff_attendance.ts` (thin wiring; `GET /records`, `GET /summary`,
`PUT /records/:userId/:date`).
- Controller: `src/api/controllers/staff_attendance.controller.ts` (custom — not the CRUD factory). - Controller: `src/api/controllers/staff_attendance.controller.ts` (custom — not the CRUD factory).
- Service (BLL): `src/services/staff_attendance.ts`. - Service (BLL): `src/services/staff_attendance.ts`.
- Repository (DAL): queries run through `db.staff_attendance_records` and `db.staff` inside the - Repository (DAL): queries run through `db.staff_attendance_records` and `db.users` inside the
service (no separate `db/api/staff_attendance.ts`). service (no separate `db/api/staff_attendance.ts`).
- Model: `src/db/models/staff_attendance_records.ts`. - Model: `src/db/models/staff_attendance_records.ts`.
- Shared used: `services/shared/access.ts` (`assertAuthenticatedTenantUser`, `requireOrganizationId`, - Shared used: `services/shared/access.ts` (`assertAuthenticatedTenantUser`, `requireOrganizationId`,
`requireUserId`, `hasRoleAccess`, `campusScope`), `services/shared/validate.ts` (`clampLimit`, `requireUserId`, `hasFeaturePermission`, `campusDimensionScope`, `getRoleScope`, `getSchoolId`), `services/shared/validate.ts` (`clampLimit`,
`optionalIsoDate`), `shared/constants/staff-attendance.ts` `optionalIsoDate`), `shared/constants/staff-attendance.ts`
(`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_REPORT_ROLE_NAMES`, (`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`,
`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`, `STAFF_ATTENDANCE_MAX_LIMIT`).
`STAFF_ATTENDANCE_MAX_LIMIT`), `shared/constants/staff.ts` (`STAFF_STATUSES`).
## API ## API
@ -38,6 +38,9 @@ The slice is mounted at `/api/staff_attendance`; all routes require JWT authenti
`{ staffCount, recordsCount, present, late, absent }`. Accepts the same `startDate`, `endDate`, and `{ staffCount, recordsCount, present, late, absent }`. Accepts the same `startDate`, `endDate`, and
`limit` query parameters; `limit` is read from the filter type but the summary counts are computed `limit` query parameters; `limit` is read from the filter type but the summary counts are computed
with SQL `COUNT` aggregates, not by limiting rows. with SQL `COUNT` aggregates, not by limiting rows.
- `PUT /api/staff_attendance/records/:userId/:date` -> `200` record DTO. Body:
`{ data: { status, note } }`, where `status` is `present`, `late`, or `absent`, and `note` is
nullable/optional text. The route requires `FILL_ATTENDANCE`.
Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` raises Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` raises
`ValidationError`. `ValidationError`.
@ -46,16 +49,17 @@ Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit`
Enforced by `visibilityScope` in the service: Enforced by `visibilityScope` in the service:
- A user who does NOT hold a report role (`STAFF_ATTENDANCE_REPORT_ROLE_NAMES`) sees only their own - A user who does NOT have `READ_STAFF_ATTENDANCE_REPORTS` sees only their own
records, scoped by `userId` (`requireUserId`). records, scoped by `userId` (`requireUserId`).
- A user who holds a report role sees campus-scoped records via `campusScope`: tenant-wide roles - A user with `READ_STAFF_ATTENDANCE_REPORTS` sees scope-filtered records:
(`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`) or users with `globalAccess` see all organization organization-wide for owner/superintendent, school campuses plus users directly
records; other report-role users are restricted to their own campus (`campusId` from their staff assigned to that school for principal/registrar, and a single campus for
profile, else unrestricted if no campus resolves). director/campus scope.
- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles super_admin, system_admin, - A user with `FILL_ATTENDANCE` can upsert staff attendance only for staff users inside their
owner, superintendent, director. effective scope: organization office users at organization scope, school office users at school
- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = super_admin, system_admin, owner. scope, or campus users at campus/class scope.
- `globalAccess` on the user's app role grants access in any role check (`hasRoleAccess`). - Role-seeded permissions are only the baseline grants. `custom_permissions` can grant permissions
and `custom_permissions_filter` can remove them for non-global users.
Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or
resolvable organization raises `ForbiddenError`. resolvable organization raises `ForbiddenError`.
@ -63,9 +67,11 @@ resolvable organization raises `ForbiddenError`.
## Tenant Scope ## Tenant Scope
- Every query is bound to the current user's organization (`requireOrganizationId`). - Every query is bound to the current user's organization (`requireOrganizationId`).
- Within the organization, visibility is narrowed to the user, their campus, or the whole tenant per - Within the organization, visibility is narrowed to the user, their campus, their school, or the
the access rules above. The summary's `staffCount` query applies the same `campusScope` over the whole tenant per the access rules above. The summary's `staffCount` query applies the same scope
`staff` table (active staff only). over internal-role users. For school scope, it includes users with `schoolId` equal to the active
school as well as users assigned to campuses under that school; for organization scope, it includes
organization office, school, and campus staff.
## Data Contract ## Data Contract
@ -73,9 +79,8 @@ Record DTO returned by `GET /records` (`toRecordDto`): `id`, `date` (from `atten
`status`, `note`, `user_name`, `user_role`, `organizationId`, `campusId`, `userId`, `createdAt`, `status`, `note`, `user_name`, `user_role`, `organizationId`, `campusId`, `userId`, `createdAt`,
`updatedAt`. `updatedAt`.
Summary DTO returned by `GET /summary`: `staffCount` (active staff in scope, from the `staff` table Summary DTO returned by `GET /summary`: `staffCount` (internal-role users in scope), `recordsCount`
filtered by `STAFF_STATUSES.ACTIVE`), `recordsCount` (= `present + late + absent`), `present`, `late`, (= `present + late + absent`), `present`, `late`, `absent`.
`absent`.
Model `staff_attendance_records` fields: `id` (UUID PK), `attendance_date` (DATEONLY, not null), Model `staff_attendance_records` fields: `id` (UUID PK), `attendance_date` (DATEONLY, not null),
`status` (ENUM of `STAFF_ATTENDANCE_STATUSES``present`, `late`, `absent`; not null), `note` `status` (ENUM of `STAFF_ATTENDANCE_STATUSES``present`, `late`, `absent`; not null), `note`
@ -97,11 +102,13 @@ Associations (`belongsTo`): `organization`, `campus`, `user`, `createdBy`, `upda
## Tests ## Tests
None yet (no `staff_attendance` unit/e2e test in `src/`). - `src/services/staff_attendance.test.ts` verifies school report scope includes both school-owned
users and campuses under the school, and that school-scope office attendance upserts are scoped to
school office users.
## Related ## Related
- Frontend: `frontend/docs/staff-attendance-integration.md`. - Frontend: `frontend/docs/staff-attendance-integration.md`.
- Related slices: `campus-attendance` (campus daily aggregates), the student-level - Related slices: `campus-attendance` (campus daily aggregates), the student-level
`attendance_sessions` / `attendance_records` slices, and `staff` (active staff count, campus `attendance_sessions` / `attendance_records` slices, and `users` (active employee count, campus
resolution). resolution).

View File

@ -1,96 +0,0 @@
# Staff Backend
## Purpose
`staff` is the per-organization employee profile roster, linking an optional `user` account and
`campus` to each staff member. It is a generic-CRUD slice assembled from the shared factories;
the backend is the source of truth for staff records.
## Slice Files (by layer)
- Route: `src/routes/staff.ts``createCrudRouter(controller, { permission: 'staff' })`.
- Controller: `src/api/controllers/staff.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/staff.ts``createCrudService(DbApi, { notFoundCode: 'staffNotFound' })`.
- Repository (DAL): `src/db/api/staff.ts` (`StaffDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/staff.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `db/utils.ts` (`Utils`),
`db/api/file.ts` (`FileDBApi.replaceRelationFiles` for the `photo` relation).
## API
The standard generic-CRUD surface (all under `/api/staff`, JWT + `${METHOD}_STAFF` permission,
all `200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is
`employee_number`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `employee_number`, `job_title`, `hire_date`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('staff')`, deriving
`READ_STAFF` / `CREATE_STAFF` / `UPDATE_STAFF` / `DELETE_STAFF` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org), and only when
`data.organization` is provided.
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `employee_number`, `job_title` (TEXT, nullable).
- `staff_type` — ENUM `teacher` | `admin` | `support`.
- `status` — ENUM `active` | `on_leave` | `inactive`.
- `hire_date` — DATE.
- `importHash` (unique), `campusId`, `organizationId`, `userId`, `createdById`, `updatedById`,
timestamps.
Associations: `belongsTo` organization, campus, user (a `users` record), createdBy/updatedBy
(users); `hasMany` `classes_homeroom_teacher` (classes via `homeroom_teacherId`),
`class_subjects_teacher` (class_subjects via `teacherId`), `attendance_sessions_taken_by`
(attendance_sessions via `taken_byId`);
`hasMany` file as `photo` (scoped relation). `findBy`/`GET /:id` eager-load all of these in a
single `Promise.all`.
List filters (`StaffFilter`): `id`, `employee_number`, `job_title`, `hire_dateRange`,
`staff_type`, `status`, `campus` (id or name, `|`-separated), `user` (id or `firstName`,
`|`-separated), `organization` (id list, `|`-separated), `createdAtRange`, plus `field`/`sort`
ordering and `limit`/`page` pagination.
## Behavior / Notes
- `create`/`bulkImport`/`update` manage the `photo` file relation via
`FileDBApi.replaceRelationFiles`.
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `StaffFilter` accepts an `active` flag and `findAll` filters on an `active` column, but
the model has no `active` column; this filter is currently inert (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `organizations`, `campuses`,
`classes`, `class_subjects`, `attendance_sessions`, `file.md`, `permissions.md`.

View File

@ -122,7 +122,7 @@ const req = createMockRequest({
|------|-------------|-------| |------|-------------|-------|
| `middlewares/error-handler.test.ts` | Error normalization | ~10 | | `middlewares/error-handler.test.ts` | Error normalization | ~10 |
| `db/api/shared/repository.test.ts` | Repository base | ~10 | | `db/api/shared/repository.test.ts` | Repository base | ~10 |
| `shared/architecture/import-boundaries.test.ts` | Architecture validation | ~5 | | `shared/architecture/import-boundaries.test.ts` | Backend import-boundary validation: layer assignment, API/BLL/DAL/shared direction, exact exception allowlists | ~8 |
## Testing Patterns ## Testing Patterns

View File

@ -4,8 +4,9 @@
`user_progress` stores per-user progress for narrow staff workflows, keyed by a typed `user_progress` stores per-user progress for narrow staff workflows, keyed by a typed
`progress_type` and an `item_id`. Current supported types are `sign_learned` (sign-language items `progress_type` and an `item_id`. Current supported types are `sign_learned` (sign-language items
learned) and `zone_checkin` (zones-of-regulation check-ins). The backend owns tenant scope, user learned), `zone_checkin` (zones-of-regulation check-ins), and `classroom_strategy_favorite`
ownership, validation, and persistence (one row per user + type + item, upserted). (Classroom Support favorite strategy IDs). The backend owns tenant scope, user ownership,
validation, and persistence (one row per user + type + item, upserted).
## Slice Files (by layer) ## Slice Files (by layer)
@ -52,8 +53,9 @@ All routes require JWT authentication. Base path mounted at `/api/user_progress`
## Data Contract ## Data Contract
- Mutation input (`UserProgressInput`): `progress_type` (must be one of - Mutation input (`UserProgressInput`): `progress_type` (must be one of
`USER_PROGRESS_TYPE_VALUES`: `sign_learned`, `zone_checkin`) and `item_id` (non-empty string) are `USER_PROGRESS_TYPE_VALUES`: `sign_learned`, `zone_checkin`, `classroom_strategy_favorite`) and
required. Optional: `value`, `score`, `metadata`. Invalid input raises `ValidationError`. `item_id` (non-empty string) are required. Optional: `value`, `score`, `metadata`. Invalid input
raises `ValidationError`.
- On save, `value` is persisted only if a string (else `null`), `score` only if a number (else - On save, `value` is persisted only if a string (else `null`), `score` only if a number (else
`null`), and `metadata` defaults to `null` when absent; `item_id` is trimmed. `null`), and `metadata` defaults to `null` when absent; `item_id` is trimmed.
- DTO fields: `id`, `progress_type`, `item_id`, `value`, `score`, `metadata`, `organizationId`, - DTO fields: `id`, `progress_type`, `item_id`, `value`, `score`, `metadata`, `organizationId`,
@ -76,11 +78,13 @@ All routes require JWT authentication. Base path mounted at `/api/user_progress`
## Tests ## Tests
None yet (no `user_progress` unit/e2e test in `src/`). `src/services/user_progress.test.ts` covers own-scope persistence guards and classroom strategy
favorite upsert payload ownership.
## Related ## Related
- Frontend: `frontend/docs/user-progress-integration.md`, - Frontend: `frontend/docs/user-progress-integration.md`,
`frontend/docs/sign-language-integration.md`, `frontend/docs/zones-of-regulation-integration.md`. `frontend/docs/sign-language-integration.md`, `frontend/docs/zones-of-regulation-integration.md`,
`frontend/docs/classroom-support-integration.md`.
- Related slices: `safety-quiz-results.md`, `personality-quiz-results.md`, - Related slices: `safety-quiz-results.md`, `personality-quiz-results.md`,
`walkthrough-checkins.md`. `walkthrough-checkins.md`.

View File

@ -48,8 +48,8 @@ record when `req.params.id`/`req.body.id` equals their own id.
attachment of fields `id, firstName, lastName, phoneNumber, email`. attachment of fields `id, firstName, lastName, phoneNumber, email`.
- `GET /api/users/count` -> `200` `{ rows: [], count }`. - `GET /api/users/count` -> `200` `{ rows: [], count }`.
- `GET /api/users/autocomplete` -> `200` array of `{ id, label }`. - `GET /api/users/autocomplete` -> `200` array of `{ id, label }`.
- `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions, staff - `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions,
profile, custom permissions, organization). custom permissions, organization).
## Access Rules ## Access Rules
@ -79,14 +79,13 @@ Model columns (`src/db/models/users.ts`): `id` (UUID PK), `firstName`, `lastName
(text), `email` (text, `allowNull: false`), `disabled` (boolean, default false), `password` (text), `email` (text, `allowNull: false`), `disabled` (boolean, default false), `password`
(text), `emailVerified` (boolean, default false), `emailVerificationToken` + (text), `emailVerified` (boolean, default false), `emailVerificationToken` +
`emailVerificationTokenExpiresAt`, `passwordResetToken` + `passwordResetTokenExpiresAt`, `emailVerificationTokenExpiresAt`, `passwordResetToken` + `passwordResetTokenExpiresAt`,
`provider` (text), `importHash` (unique), `organizationId`, `createdById`, `updatedById`, `provider` (text), `organizationId`, `createdById`, `updatedById`,
`createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete). A `beforeCreate`/`beforeUpdate` `createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete). A `beforeCreate`/`beforeUpdate`
hook trims `email`/`firstName`/`lastName`; for non-local OAuth providers `beforeCreate` forces hook trims `email`/`firstName`/`lastName`; for non-local OAuth providers `beforeCreate` forces
`emailVerified = true` and generates a random bcrypt password when none is supplied. `emailVerified = true` and generates a random bcrypt password when none is supplied.
Associations: `belongsToMany permissions` as `custom_permissions` (and `custom_permissions_filter` Associations: `belongsToMany permissions` as `custom_permissions` (and `custom_permissions_filter`
for list filtering) through `usersCustom_permissionsPermissions`; `hasMany staff` as `staff_user`; for list filtering) through `usersCustom_permissionsPermissions`; `hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo
`hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo
organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` as organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` as
`createdBy`/`updatedBy`. `createdBy`/`updatedBy`.
@ -97,12 +96,12 @@ layer (`services/users.ts`) also enforces the relational role policy
(`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global (`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global
actors, and auto-creates the company when an `owner` is created (§3.3/§3.4). actors, and auto-creates the company when an `owner` is created (§3.3/§3.4).
List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `password`, List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `provider`
`emailVerificationToken`, `passwordResetToken`, `provider` (ILIKE); (ILIKE); `createdAtRange`; `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated);
`emailVerificationTokenExpiresAtRange`, `passwordResetTokenExpiresAtRange`, `createdAtRange`; `campusId` (direct campus users plus class-scoped users whose class belongs to the campus);
`active`, `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated); `classId` (direct class-scoped users plus students enrolled through `class_enrollments`);
`organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus `field`/`sort` `organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus
(default `createdAt desc`) and `limit`/`page`. `field`/`sort` (default `createdAt desc`) and `limit`/`page`.
## Behavior / Notes ## Behavior / Notes
@ -120,7 +119,7 @@ List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`,
when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are
emailed while bulk-imported users are not. emailed while bulk-imported users are not.
- All mutations run inside a manual Sequelize transaction (commit on success, rollback on error). - All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
- `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, staff profile, - `findBy` returns a per-request `AuthenticatedUser` (role + its permissions,
custom permissions, organization) used by authentication/authorization; `findProfileById` custom permissions, organization) used by authentication/authorization; `findProfileById`
returns the trimmed profile DTO for `GET /me`. returns the trimmed profile DTO for `GET /me`.

View File

@ -19,11 +19,9 @@ backend owns tenant scope, campus scope, creator ownership, validation, and role
- Model: `src/db/models/walkthrough_checkins.ts`. - Model: `src/db/models/walkthrough_checkins.ts`.
- Shared used: `services/shared/validate.ts` (`nullableString`); `db/with-transaction.ts` - Shared used: `services/shared/validate.ts` (`nullableString`); `db/with-transaction.ts`
(`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`); (`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`);
`shared/constants/walkthrough.ts` (`WALKTHROUGH_MANAGER_ROLE_NAMES`, `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `hasFeaturePermission`,
`WALKTHROUGH_TENANT_WIDE_ROLE_NAMES`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `hasGlobalAccess`, `requireUserId`); `shared/errors/*` (`ForbiddenError`,
`hasGlobalAccess`, `hasRoleAccess`, `requireUserId`); `shared/errors/*` (`ForbiddenError`, `ValidationError`).
`ValidationError`). Note: the service defines a module-local `getCampusId` and `campusScope`
(staff-profile campus only), not the shared access helpers.
## API ## API
@ -39,10 +37,9 @@ All routes require JWT authentication. Base path mounted at `/api/walkthrough_ch
## Access Rules ## Access Rules
- All operations require a role in `WALKTHROUGH_MANAGER_ROLE_NAMES` (`super_admin`, - All operations require `MANAGE_WALKTHROUGH`. Role-seeded permissions are only
`system_admin`, `owner`, `superintendent`, `director`) or `globalAccess`, the baseline grants. `custom_permissions` can grant it and
enforced by `assertCanManage` (which also requires an authenticated user); otherwise `custom_permissions_filter` can remove it for non-global users.
`ForbiddenError`. Users with `globalAccess` are always allowed.
- Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`super_admin`, - Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`super_admin`,
`system_admin`, `owner`, `superintendent`) or `globalAccess` see all org records; `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 other managers (e.g. `director`) are restricted to their own staff campus on `list` and
@ -52,9 +49,7 @@ All routes require JWT authentication. Base path mounted at `/api/walkthrough_ch
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org - Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
filter and see check-ins across all organizations; regular users are bound to their org. filter and see check-ins across all organizations; regular users are bound to their org.
- `campusId` is resolved from the module-local `getCampusId` — the current staff profile's campus - `campusId` is resolved from the current user's direct campus scope via `getCampusId`, else `null`.
only (`currentUser.staff_user[0].campusId`), else `null`; it never falls back to the user's own
`campusId`.
- On create, `createdById` is required from the current user (`requireUserId`); `updatedById` from - On create, `createdById` is required from the current user (`requireUserId`); `updatedById` from
the current user. the current user.

View File

@ -25,20 +25,26 @@ logic, keeping the generic `user_progress` endpoint generic.
## Routes (`/api/zone_checkins`) ## Routes (`/api/zone_checkins`)
All require `ZONE_CHECKIN` (the four campus staff roles). All require explicit `ZONE_CHECKIN`; `globalAccess` alone does not imply this
personal workflow permission.
- `GET /today``{ date, zone, isCheckedInToday }` (campus-local date). - `GET /today``{ date, zone, isCheckedInToday }` (campus-local date).
- `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red). - `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red).
If the caller is a parent-scope user acting through a drilled child scope, the request is accepted
as a no-op and returns today's existing personal state without saving a new row.
- `DELETE /today` → clear today's check-in. - `DELETE /today` → clear today's check-in.
- `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`). - `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`).
## Authorization ## Authorization
- `ZONE_CHECKIN``director` (full access), `office_manager` (via - `ZONE_CHECKIN` is seeded for `director`, `office_manager`, `teacher`, and
`...MODULE_ACTIONS`), `teacher`, `support_staff` (explicit grants). Other roles `support_staff`. Other users can receive or lose it only through effective
(owner/superintendent/student/guardian/system) are not granted it; the frontend permissions (`custom_permissions` / `custom_permissions_filter`). The frontend
also gates the nudge to the four campus roles (`canZoneCheckIn`). Reads/writes and backend both gate this workflow by permission, not by role name.
are scoped to the caller's own `userId` by `UserProgressService`. Reads/writes are scoped to the caller's own `userId` by `UserProgressService`.
- Check-ins persist only when the active scope is the user's own scope. Parent users drilled into a
child school/campus/classroom do not see the personal check-in UI there, and the backend does not
create reportable `user_progress` rows for that child scope.
- A user with no campus has no campus-local "today" — the service rejects with a - A user with no campus has no campus-local "today" — the service rejects with a
validation error (only campus staff reach these routes). validation error (only campus staff reach these routes).

View File

@ -20,8 +20,8 @@
"db:seed": "tsx src/db/umzug.ts seed:up", "db:seed": "tsx src/db/umzug.ts seed:up",
"db:seed:undo": "tsx src/db/umzug.ts seed:down", "db:seed:undo": "tsx src/db/umzug.ts seed:down",
"db:reset": "tsx src/db/reset.ts", "db:reset": "tsx src/db/reset.ts",
"db:cleanup-tokens": "tsx src/db/cleanup-refresh-tokens.ts", "db:cleanup-tokens": "tsx src/commands/cleanup-refresh-tokens.ts",
"db:cleanup-tokens:prod": "node dist/db/cleanup-refresh-tokens.js", "db:cleanup-tokens:prod": "node dist/commands/cleanup-refresh-tokens.js",
"watch": "tsx watcher.ts" "watch": "tsx watcher.ts"
}, },
"dependencies": { "dependencies": {

View File

@ -82,6 +82,14 @@ export async function me(req: Request, res: Response): Promise<void> {
res.status(200).send(payload); res.status(200).send(payload);
} }
export async function updateMe(req: Request, res: Response): Promise<void> {
if (!req.currentUser || !req.currentUser.id) {
throw new ForbiddenError();
}
const payload = await AuthService.updateOwnProfile(req.body.data, req);
res.status(200).send(payload);
}
export async function passwordReset(req: Request, res: Response): Promise<void> { export async function passwordReset(req: Request, res: Response): Promise<void> {
const payload = await AuthService.passwordReset( const payload = await AuthService.passwordReset(
req.body.token, req.body.token,

View File

@ -0,0 +1,17 @@
import type { Request, Response } from 'express';
import ClassAttendanceService from '@/services/class_attendance';
export async function summary(req: Request, res: Response): Promise<void> {
const payload = await ClassAttendanceService.summary(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function upsert(req: Request, res: Response): Promise<void> {
const payload = await ClassAttendanceService.upsert(
req.params.classId,
req.params.date,
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}

View File

@ -1,26 +1,9 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import CommunicationsService from '@/services/communications'; import CommunicationsService from '@/services/communications';
export async function listParentMessages( function routeParam(value: string | string[] | undefined): string {
req: Request, if (Array.isArray(value)) return value[0] ?? '';
res: Response, return value ?? '';
): Promise<void> {
const payload = await CommunicationsService.listParentMessages(
req.query,
req.currentUser,
);
res.status(200).send(payload);
}
export async function createParentMessage(
req: Request,
res: Response,
): Promise<void> {
const payload = await CommunicationsService.createParentMessage(
req.body.data,
req.currentUser,
);
res.status(201).send(payload);
} }
export async function listEvents(req: Request, res: Response): Promise<void> { export async function listEvents(req: Request, res: Response): Promise<void> {
@ -38,3 +21,29 @@ export async function createEvent(req: Request, res: Response): Promise<void> {
); );
res.status(201).send(payload); res.status(201).send(payload);
} }
export async function updateEvent(req: Request, res: Response): Promise<void> {
const payload = await CommunicationsService.updateEvent(
routeParam(req.params.id),
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}
export async function deleteEvent(req: Request, res: Response): Promise<void> {
await CommunicationsService.deleteEvent(
routeParam(req.params.id),
req.currentUser,
);
res.status(204).send();
}
export async function cancelEvent(req: Request, res: Response): Promise<void> {
const payload = await CommunicationsService.cancelEvent(
routeParam(req.params.id),
req.body.data,
req.currentUser,
);
res.status(201).send(payload);
}

View File

@ -14,6 +14,15 @@ export async function create(req: Request, res: Response): Promise<void> {
res.status(201).send(payload); res.status(201).send(payload);
} }
/** Authenticated read for any tenant user; returns content scoped to the user. */
export async function readByType(req: Request, res: Response): Promise<void> {
const payload = await ContentCatalogService.findByType(
req.params.contentType,
req.currentUser,
);
res.status(200).send(payload);
}
export async function findManagedByType( export async function findManagedByType(
req: Request, req: Request,
res: Response, res: Response,

View File

@ -0,0 +1,26 @@
import type { Request, Response } from 'express';
import DirectMessagesService from '@/services/direct_messages';
export async function contacts(req: Request, res: Response): Promise<void> {
const payload = await DirectMessagesService.contacts(req.currentUser);
res.status(200).send(payload);
}
export async function conversations(req: Request, res: Response): Promise<void> {
const payload = await DirectMessagesService.conversations(req.currentUser);
res.status(200).send(payload);
}
export async function thread(req: Request, res: Response): Promise<void> {
const payload = await DirectMessagesService.thread(
req.params.otherUserId,
req.query.studentId,
req.currentUser,
);
res.status(200).send(payload);
}
export async function send(req: Request, res: Response): Promise<void> {
const payload = await DirectMessagesService.send(req.body.data, req.currentUser);
res.status(200).send(payload);
}

View File

@ -0,0 +1,21 @@
import type { Request, Response } from 'express';
import GuardianStudentsService from '@/services/guardian_students';
export async function list(req: Request, res: Response): Promise<void> {
const payload = await GuardianStudentsService.list(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function link(req: Request, res: Response): Promise<void> {
const payload = await GuardianStudentsService.link(
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}
export async function unlink(req: Request, res: Response): Promise<void> {
const id = String(req.params.id);
await GuardianStudentsService.unlink(id, req.currentUser);
res.status(200).send({ id });
}

View File

@ -0,0 +1,6 @@
import type { Request, Response } from 'express';
import IamCapabilitiesService from '@/services/iam_capabilities';
export function current(req: Request, res: Response): void {
res.status(200).send(IamCapabilitiesService.current(req.currentUser));
}

View File

@ -0,0 +1,7 @@
import type { Request, Response } from 'express';
import PlatformService from '@/services/platform';
export async function stats(req: Request, res: Response): Promise<void> {
const payload = await PlatformService.stats(req.currentUser);
res.status(200).send(payload);
}

View File

@ -9,6 +9,11 @@ export async function list(req: Request, res: Response): Promise<void> {
res.status(200).send(payload); res.status(200).send(payload);
} }
export async function report(req: Request, res: Response): Promise<void> {
const payload = await PolicyAcknowledgmentsService.report(req.currentUser);
res.status(200).send(payload);
}
export async function acknowledge(req: Request, res: Response): Promise<void> { export async function acknowledge(req: Request, res: Response): Promise<void> {
const payload = await PolicyAcknowledgmentsService.acknowledge( const payload = await PolicyAcknowledgmentsService.acknowledge(
req.body.data, req.body.data,

View File

@ -1,10 +0,0 @@
import type { Request, Response } from 'express';
import { paramStr } from '@/api/http/request';
import ContentCatalogService from '@/services/content_catalog';
export async function findByType(req: Request, res: Response): Promise<void> {
const payload = await ContentCatalogService.findByType(
paramStr(req.params.contentType),
);
res.status(200).send(payload);
}

View File

@ -0,0 +1,20 @@
import type { Request, Response } from 'express';
import service from '@/services/schools';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
/** Custom: atomically create a school + its first campus. */
export async function createWithFirstCampus(
req: Request,
res: Response,
): Promise<void> {
const payload = await service.createWithFirstCampus(
req.body.data,
req.body.firstCampus,
req.currentUser,
);
res.status(200).send(payload);
}
export default createCrudController(service, {
csvFields: ['id', 'name', 'code', 'address', 'phone', 'email'],
});

View File

@ -0,0 +1,13 @@
import type { Request, Response } from 'express';
import { queryNum } from '@/api/http/request';
import ScopeService from '@/services/scope';
export async function children(req: Request, res: Response): Promise<void> {
const payload = await ScopeService.listChildren(
req.query.parentLevel,
req.query.parentId,
queryNum(req.query.limit),
req.currentUser,
);
res.status(200).send(payload);
}

View File

@ -1,4 +0,0 @@
import service from '@/services/staff';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'employee_number', 'job_title', 'hire_date'] });

View File

@ -16,3 +16,15 @@ export async function summary(req: Request, res: Response): Promise<void> {
); );
res.status(200).send(payload); res.status(200).send(payload);
} }
export async function upsertRecord(req: Request, res: Response): Promise<void> {
const payload = await StaffAttendanceService.upsertRecord(
{
...req.body.data,
userId: req.params.userId,
date: req.params.date,
},
req.currentUser,
);
res.status(200).send(payload);
}

View File

@ -20,8 +20,26 @@ function hostFromReferer(req: Request): string {
} }
export async function create(req: Request, res: Response): Promise<void> { export async function create(req: Request, res: Response): Promise<void> {
await Service.create(req.body.data, req.currentUser, true, hostFromReferer(req)); const result = await Service.create(
res.status(200).send(true); req.body.data,
req.currentUser,
true,
hostFromReferer(req),
);
res.status(200).send(result ?? true);
}
export async function createOwnerWithOrganization(
req: Request,
res: Response,
): Promise<void> {
const payload = await Service.createOwnerWithOrganization(
req.body.data,
req.currentUser,
true,
hostFromReferer(req),
);
res.status(200).send(payload);
} }
export async function bulkImport(req: Request, res: Response): Promise<void> { export async function bulkImport(req: Request, res: Response): Promise<void> {

View File

@ -3,7 +3,6 @@ import { Strategy as JwtStrategy } from 'passport-jwt';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import type { Request } from 'express'; import type { Request } from 'express';
import config from '@/shared/config'; import config from '@/shared/config';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users'; import UsersDBApi from '@/db/api/users';
import cookies from '@/auth/cookies'; import cookies from '@/auth/cookies';
@ -50,9 +49,8 @@ function socialStrategy(
provider: string, provider: string,
done: SocialDone, done: SocialDone,
): void { ): void {
db.users UsersDBApi.findOrCreateSocialIdentity(email, provider)
.findOrCreate({ where: { email, provider } }) .then((user) => done(null, { user }))
.then(([user]) => done(null, { user }))
.catch((error: unknown) => done(error)); .catch((error: unknown) => done(error));
} }

View File

@ -1,23 +1,25 @@
import db from '@/db/models'; import {
import { cleanupExpiredRefreshTokens } from '@/services/refresh-token-maintenance'; cleanupExpiredRefreshTokens,
closeRefreshTokenMaintenanceConnection,
} from '@/services/refresh-token-maintenance';
/** /**
* Operational maintenance command: delete refresh-token rows that expired before * Operational maintenance command: delete refresh-token rows that expired before
* the retention window (`AUTH_REFRESH_TOKEN_RETENTION_MS`, default 7 days). Run * the retention window (`AUTH_REFRESH_TOKEN_RETENTION_MS`, default 7 days). Run
* on a schedule (cron / platform scheduler): * on a schedule (cron / platform scheduler):
* *
* npm run db:cleanup-tokens # dev (tsx) * npm run db:cleanup-tokens # dev (tsx)
* node dist/db/cleanup-refresh-tokens.js # prod (built) * node dist/commands/cleanup-refresh-tokens.js # prod (built)
*/ */
async function run(): Promise<void> { async function run(): Promise<void> {
const { deleted, cutoff } = await cleanupExpiredRefreshTokens(); const { deleted, cutoff } = await cleanupExpiredRefreshTokens();
console.log( console.log(
`Refresh-token cleanup complete: ${deleted} row(s) removed (cutoff ${cutoff.toISOString()}).`, `Refresh-token cleanup complete: ${deleted} row(s) removed (cutoff ${cutoff.toISOString()}).`,
); );
await db.sequelize.close(); await closeRefreshTokenMaintenanceConnection();
} }
run().catch((error) => { run().catch((error: unknown) => {
console.error('Refresh-token cleanup failed:', error); console.error('Refresh-token cleanup failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -317,7 +317,7 @@ class Attendance_sessionsDBApi {
: {}, : {},
}, },
{ {
model: db.staff, model: db.users,
as: 'taken_by', as: 'taken_by',
where: filter.taken_by where: filter.taken_by
? { ? {
@ -330,7 +330,7 @@ class Attendance_sessionsDBApi {
}, },
}, },
{ {
employee_number: { email: {
[Op.or]: filter.taken_by [Op.or]: filter.taken_by
.split('|') .split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })), .map((t) => ({ [Op.iLike]: `%${t}%` })),

View File

@ -21,6 +21,8 @@ import type { CurrentUser, DbApiOptions } from '@/db/api/types';
type CampusesData = Partial<InferCreationAttributes<Campuses>> & { type CampusesData = Partial<InferCreationAttributes<Campuses>> & {
organization?: string | null; organization?: string | null;
/** Owning school (Organization → School → Campus). Optional. */
schoolId?: string | null;
}; };
interface CampusesFilter { interface CampusesFilter {
@ -69,6 +71,7 @@ class CampusesDBApi {
textColor: data.textColor || null, textColor: data.textColor || null,
bgLight: data.bgLight || null, bgLight: data.bgLight || null,
description: data.description || null, description: data.description || null,
logo: data.logo || null,
isOnline: data.isOnline || false, isOnline: data.isOnline || false,
active: data.active || false, active: data.active || false,
importHash: data.importHash || null, importHash: data.importHash || null,
@ -78,9 +81,21 @@ class CampusesDBApi {
{ transaction }, { transaction },
); );
await campuses.setOrganization(currentUser.organizationId ?? undefined, { // Org resolution: own org (non-global) → explicit org (global) → inherited
transaction, // from the chosen school. Keeps tenant isolation while letting global
}); // creators place a campus correctly.
let organizationId = currentUser.organizationId ?? data.organization ?? null;
if (!organizationId && data.schoolId) {
const school = await db.schools.findByPk(data.schoolId, {
attributes: ['organizationId'],
transaction,
});
organizationId = school?.organizationId ?? null;
}
await campuses.setOrganization(organizationId ?? undefined, { transaction });
if (data.schoolId) {
await campuses.setSchool(data.schoolId, { transaction });
}
return campuses; return campuses;
} }
@ -155,6 +170,7 @@ class CampusesDBApi {
if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight; if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight;
if (data.description !== undefined) if (data.description !== undefined)
updatePayload.description = data.description; updatePayload.description = data.description;
if (data.logo !== undefined) updatePayload.logo = data.logo;
if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline; if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline;
if (data.active !== undefined) updatePayload.active = data.active; if (data.active !== undefined) updatePayload.active = data.active;
@ -204,21 +220,18 @@ class CampusesDBApi {
const output: Record<string, unknown> = campuses.get({ plain: true }); const output: Record<string, unknown> = campuses.get({ plain: true });
const [ const [
staff_campus,
classes_campus, classes_campus,
timetables_campus, timetables_campus,
attendance_sessions_campus, attendance_sessions_campus,
messages_campus, messages_campus,
organization, organization,
] = await Promise.all([ ] = await Promise.all([
campuses.getStaff_campus({ transaction }),
campuses.getClasses_campus({ transaction }), campuses.getClasses_campus({ transaction }),
campuses.getTimetables_campus({ transaction }), campuses.getTimetables_campus({ transaction }),
campuses.getAttendance_sessions_campus({ transaction }), campuses.getAttendance_sessions_campus({ transaction }),
campuses.getMessages_campus({ transaction }), campuses.getMessages_campus({ transaction }),
campuses.getOrganization({ transaction }), campuses.getOrganization({ transaction }),
]); ]);
output.staff_campus = staff_campus;
output.classes_campus = classes_campus; output.classes_campus = classes_campus;
output.timetables_campus = timetables_campus; output.timetables_campus = timetables_campus;
output.attendance_sessions_campus = attendance_sessions_campus; output.attendance_sessions_campus = attendance_sessions_campus;

View File

@ -259,7 +259,7 @@ class Class_subjectsDBApi {
: {}, : {},
}, },
{ {
model: db.staff, model: db.users,
as: 'teacher', as: 'teacher',
where: filter.teacher where: filter.teacher
? { ? {
@ -270,7 +270,7 @@ class Class_subjectsDBApi {
}, },
}, },
{ {
employee_number: { email: {
[Op.or]: filter.teacher [Op.or]: filter.teacher
.split('|') .split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })), .map((t) => ({ [Op.iLike]: `%${t}%` })),

View File

@ -63,6 +63,7 @@ class ClassesDBApi {
section: data.section || null, section: data.section || null,
capacity: data.capacity || null, capacity: data.capacity || null,
status: data.status || null, status: data.status || null,
logo: data.logo || null,
importHash: data.importHash || null, importHash: data.importHash || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
@ -70,9 +71,17 @@ class ClassesDBApi {
{ transaction }, { transaction },
); );
await classes.setOrganization(currentUser.organizationId ?? undefined, { // Org resolution: own org (non-global) → explicit org (global) → inherited
transaction, // from the chosen campus.
}); let organizationId = currentUser.organizationId ?? data.organization ?? null;
if (!organizationId && data.campus) {
const campus = await db.campuses.findByPk(data.campus, {
attributes: ['organizationId'],
transaction,
});
organizationId = campus?.organizationId ?? null;
}
await classes.setOrganization(organizationId ?? undefined, { transaction });
await classes.setCampus(data.campus ?? undefined, { transaction }); await classes.setCampus(data.campus ?? undefined, { transaction });
await classes.setAcademic_year(data.academic_year ?? undefined, { await classes.setAcademic_year(data.academic_year ?? undefined, {
transaction, transaction,
@ -128,6 +137,7 @@ class ClassesDBApi {
if (data.section !== undefined) updatePayload.section = data.section; if (data.section !== undefined) updatePayload.section = data.section;
if (data.capacity !== undefined) updatePayload.capacity = data.capacity; if (data.capacity !== undefined) updatePayload.capacity = data.capacity;
if (data.status !== undefined) updatePayload.status = data.status; if (data.status !== undefined) updatePayload.status = data.status;
if (data.logo !== undefined) updatePayload.logo = data.logo;
updatePayload.updatedById = currentUser.id; updatePayload.updatedById = currentUser.id;
@ -306,7 +316,7 @@ class ClassesDBApi {
: {}, : {},
}, },
{ {
model: db.staff, model: db.users,
as: 'homeroom_teacher', as: 'homeroom_teacher',
where: filter.homeroom_teacher where: filter.homeroom_teacher
? { ? {
@ -319,7 +329,7 @@ class ClassesDBApi {
}, },
}, },
{ {
employee_number: { email: {
[Op.or]: filter.homeroom_teacher [Op.or]: filter.homeroom_teacher
.split('|') .split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })), .map((t) => ({ [Op.iLike]: `%${t}%` })),

View File

@ -43,6 +43,7 @@ class OrganizationsDBApi {
{ {
id: data.id || undefined, id: data.id || undefined,
name: data.name || null, name: data.name || null,
logo: data.logo || null,
importHash: data.importHash || null, importHash: data.importHash || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
@ -87,6 +88,7 @@ class OrganizationsDBApi {
const updatePayload: Partial<InferAttributes<Organizations>> = {}; const updatePayload: Partial<InferAttributes<Organizations>> = {};
if (data.name !== undefined) updatePayload.name = data.name; if (data.name !== undefined) updatePayload.name = data.name;
if (data.logo !== undefined) updatePayload.logo = data.logo;
updatePayload.updatedById = currentUser.id; updatePayload.updatedById = currentUser.id;
@ -129,7 +131,6 @@ class OrganizationsDBApi {
academic_years_organization, academic_years_organization,
grades_organization, grades_organization,
subjects_organization, subjects_organization,
staff_organization,
classes_organization, classes_organization,
class_enrollments_organization, class_enrollments_organization,
class_subjects_organization, class_subjects_organization,
@ -147,7 +148,6 @@ class OrganizationsDBApi {
organizations.getAcademic_years_organization({ transaction }), organizations.getAcademic_years_organization({ transaction }),
organizations.getGrades_organization({ transaction }), organizations.getGrades_organization({ transaction }),
organizations.getSubjects_organization({ transaction }), organizations.getSubjects_organization({ transaction }),
organizations.getStaff_organization({ transaction }),
organizations.getClasses_organization({ transaction }), organizations.getClasses_organization({ transaction }),
organizations.getClass_enrollments_organization({ transaction }), organizations.getClass_enrollments_organization({ transaction }),
organizations.getClass_subjects_organization({ transaction }), organizations.getClass_subjects_organization({ transaction }),
@ -165,7 +165,6 @@ class OrganizationsDBApi {
output.academic_years_organization = academic_years_organization; output.academic_years_organization = academic_years_organization;
output.grades_organization = grades_organization; output.grades_organization = grades_organization;
output.subjects_organization = subjects_organization; output.subjects_organization = subjects_organization;
output.staff_organization = staff_organization;
output.classes_organization = classes_organization; output.classes_organization = classes_organization;
output.class_enrollments_organization = class_enrollments_organization; output.class_enrollments_organization = class_enrollments_organization;
output.class_subjects_organization = class_subjects_organization; output.class_subjects_organization = class_subjects_organization;

View File

@ -10,8 +10,12 @@ import {
deleteRecordsByIds, deleteRecordsByIds,
autocompleteByField, autocompleteByField,
findOwnedByPk, findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository'; } from '@/db/api/shared/repository';
import {
getOwnTenant,
tenantExactWhere,
tenantStamp,
} from '@/shared/tenancy';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination'; import { resolvePagination } from '@/shared/constants/pagination';
import { import {
@ -70,6 +74,8 @@ class Policy_documentsDBApi {
): Promise<PolicyDocuments> { ): Promise<PolicyDocuments> {
const currentUser = options?.currentUser ?? NO_USER; const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction; const transaction = options?.transaction;
// Per-tenant content: a new document is owned by the author's own tenant level.
const stamp = tenantStamp(getOwnTenant(currentUser));
const record = await db.policy_documents.create( const record = await db.policy_documents.create(
{ {
@ -85,8 +91,10 @@ class Policy_documentsDBApi {
version: data.version ?? 1, version: data.version ?? 1,
active: data.active ?? true, active: data.active ?? true,
importHash: data.importHash || null, importHash: data.importHash || null,
organizationId: currentUser.organizationId ?? null, organizationId: stamp.organizationId,
campusId: data.campus ?? currentUser.campusId ?? null, schoolId: stamp.schoolId,
campusId: stamp.campusId,
classId: stamp.classId,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
}, },
@ -102,6 +110,7 @@ class Policy_documentsDBApi {
): Promise<PolicyDocuments[]> { ): Promise<PolicyDocuments[]> {
const currentUser = options?.currentUser ?? NO_USER; const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction; const transaction = options?.transaction;
const stamp = tenantStamp(getOwnTenant(currentUser));
const rows = data.map((item, index) => ({ const rows = data.map((item, index) => ({
id: item.id || undefined, id: item.id || undefined,
@ -115,8 +124,10 @@ class Policy_documentsDBApi {
version: item.version ?? 1, version: item.version ?? 1,
active: item.active ?? true, active: item.active ?? true,
importHash: item.importHash || null, importHash: item.importHash || null,
organizationId: currentUser.organizationId ?? null, organizationId: stamp.organizationId,
campusId: item.campus ?? currentUser.campusId ?? null, schoolId: stamp.schoolId,
campusId: stamp.campusId,
classId: stamp.classId,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
@ -189,7 +200,7 @@ class Policy_documentsDBApi {
const transaction = options?.transaction; const transaction = options?.transaction;
const record = await db.policy_documents.findOne({ const record = await db.policy_documents.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) }, where: { ...where, ...tenantExactWhere(getOwnTenant(options?.currentUser)) },
transaction, transaction,
}); });
if (!record) { if (!record) {
@ -208,17 +219,15 @@ class Policy_documentsDBApi {
static async findAll( static async findAll(
filter: PolicyDocumentsFilter, filter: PolicyDocumentsFilter,
globalAccess: boolean, _globalAccess: boolean,
options?: DbApiOptions, options?: DbApiOptions,
): Promise<{ rows: PolicyDocuments[]; count: number }> { ): Promise<{ rows: PolicyDocuments[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page); const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {}; // Per-tenant content: documents dedicated to the user's own tenant level.
let where: WhereAttributeHash = {
const userOrganizations = options?.currentUser?.organizations?.id ?? null; ...tenantExactWhere(getOwnTenant(options?.currentUser)),
if (userOrganizations && options?.currentUser?.organizationId) { };
where.organizationId = options.currentUser.organizationId;
}
if (filter.id) { if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) }; where = { ...where, id: Utils.uuid(filter.id) };
@ -241,19 +250,6 @@ class Policy_documentsDBApi {
active: filter.active === true || filter.active === 'true', 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][] = const order: [string, string][] =
filter.field && filter.sort filter.field && filter.sort

View File

@ -0,0 +1,269 @@
import {
Op,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import ValidationError from '@/shared/errors/validation';
import type { Schools } from '@/db/models/schools';
import type { CurrentUser, DbApiOptions } from '@/db/api/types';
type SchoolsData = Partial<InferCreationAttributes<Schools>> & {
organization?: string | null;
};
interface SchoolsFilter {
limit?: number | string;
page?: number | string;
id?: string;
name?: string;
code?: string;
address?: string;
phone?: string;
email?: string;
active?: boolean | string;
organization?: string;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
class SchoolsDBApi {
static async create(
data: SchoolsData,
options?: DbApiOptions,
): Promise<Schools> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
if (data.name == null || data.code == null) {
throw new ValidationError();
}
const school = await db.schools.create(
{
id: data.id || undefined,
name: data.name,
code: data.code,
address: data.address || null,
phone: data.phone || null,
email: data.email || null,
description: data.description || null,
logo: data.logo || null,
active: data.active || false,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
// Non-global creators are pinned to their own org (tenant isolation);
// global creators (no own org) may target an org explicitly via `data`.
await school.setOrganization(
currentUser.organizationId ?? data.organization ?? undefined,
{ transaction },
);
return school;
}
static async bulkImport(
data: SchoolsData[],
options?: DbApiOptions,
): Promise<Schools[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const schoolsData = data.map((item, index) => {
if (item.name == null || item.code == null) {
throw new ValidationError();
}
return {
id: item.id || undefined,
name: item.name,
code: item.code,
address: item.address || null,
phone: item.phone || null,
email: item.email || null,
description: item.description || null,
logo: item.logo || null,
active: item.active || false,
importHash: item.importHash || null,
organizationId: currentUser.organizationId ?? null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
};
});
return db.schools.bulkCreate(schoolsData, { transaction });
}
static async update(
id: string,
data: SchoolsData,
options?: DbApiOptions,
): Promise<Schools | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const school = await findOwnedByPk(db.schools, id, options);
if (!school) {
return null;
}
const updatePayload: Partial<InferAttributes<Schools>> = {};
if (data.name !== undefined) updatePayload.name = data.name;
if (data.code !== undefined) updatePayload.code = data.code;
if (data.address !== undefined) updatePayload.address = data.address;
if (data.phone !== undefined) updatePayload.phone = data.phone;
if (data.email !== undefined) updatePayload.email = data.email;
if (data.description !== undefined)
updatePayload.description = data.description;
if (data.logo !== undefined) updatePayload.logo = data.logo;
if (data.active !== undefined) updatePayload.active = data.active;
updatePayload.updatedById = currentUser.id;
await school.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await school.setOrganization(orgId ?? undefined, { transaction });
}
return school;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Schools[]> {
return deleteRecordsByIds(db.schools, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Schools | null> {
return removeRecord(db.schools, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const school = await db.schools.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
include: [
{ model: db.organizations, as: 'organization' },
{ model: db.campuses, as: 'campuses_school' },
],
transaction,
});
if (!school) {
return null;
}
return school.get({ plain: true });
}
static async findAll(
filter: SchoolsFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Schools[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.name) {
where = { ...where, [Op.and]: Utils.ilike('schools', 'name', filter.name) };
}
if (filter.code) {
where = { ...where, [Op.and]: Utils.ilike('schools', 'code', filter.code) };
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.schools.findAndCountAll({
where,
include: [{ model: db.organizations, as: 'organization' }],
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.schools,
'name',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default SchoolsDBApi;

View File

@ -1,416 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import FileDBApi from '@/db/api/file';
import type { Staff } from '@/db/models/staff';
import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types';
type StaffData = Partial<InferCreationAttributes<Staff>> & {
organization?: string | null;
campus?: string | null;
user?: string | null;
photo?: FileInput | FileInput[] | null;
};
interface StaffFilter {
limit?: number | string;
page?: number | string;
id?: string;
employee_number?: string;
job_title?: string;
hire_dateRange?: Array<string | null | undefined>;
active?: boolean | string;
staff_type?: string;
status?: string;
campus?: string;
user?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
function staffTableName(): string {
const name = db.staff.getTableName();
return typeof name === 'string' ? name : name.tableName;
}
class StaffDBApi {
static async create(
data: StaffData,
options?: DbApiOptions,
): Promise<Staff> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const staff = await db.staff.create(
{
id: data.id || undefined,
employee_number: data.employee_number || null,
job_title: data.job_title || null,
staff_type: data.staff_type || null,
hire_date: data.hire_date || null,
status: data.status || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await staff.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await staff.setCampus(data.campus ?? undefined, { transaction });
await staff.setUser(data.user ?? undefined, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: staffTableName(),
belongsToColumn: 'photo',
belongsToId: staff.id,
},
data.photo,
options,
);
return staff;
}
static async bulkImport(
data: StaffData[],
options?: DbApiOptions,
): Promise<Staff[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const staffData = data.map((item, index) => ({
id: item.id || undefined,
employee_number: item.employee_number || null,
job_title: item.job_title || null,
staff_type: item.staff_type || null,
hire_date: item.hire_date || null,
status: item.status || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
const staff = await db.staff.bulkCreate(staffData, { transaction });
for (let i = 0; i < staff.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: staffTableName(),
belongsToColumn: 'photo',
belongsToId: staff[i].id,
},
data[i].photo,
options,
);
}
return staff;
}
static async update(
id: string,
data: StaffData,
options?: DbApiOptions,
): Promise<Staff | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const staff = await findOwnedByPk(db.staff, id, options);
if (!staff) {
return null;
}
const updatePayload: Partial<InferAttributes<Staff>> = {};
if (data.employee_number !== undefined)
updatePayload.employee_number = data.employee_number;
if (data.job_title !== undefined) updatePayload.job_title = data.job_title;
if (data.staff_type !== undefined)
updatePayload.staff_type = data.staff_type;
if (data.hire_date !== undefined) updatePayload.hire_date = data.hire_date;
if (data.status !== undefined) updatePayload.status = data.status;
updatePayload.updatedById = currentUser.id;
await staff.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await staff.setOrganization(orgId ?? undefined, { transaction });
}
if (data.campus !== undefined) {
await staff.setCampus(data.campus ?? undefined, { transaction });
}
if (data.user !== undefined) {
await staff.setUser(data.user ?? undefined, { transaction });
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: staffTableName(),
belongsToColumn: 'photo',
belongsToId: staff.id,
},
data.photo,
options,
);
return staff;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Staff[]> {
return deleteRecordsByIds(db.staff, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Staff | null> {
return removeRecord(db.staff, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const staff = await db.staff.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!staff) {
return null;
}
const output: Record<string, unknown> = staff.get({ plain: true });
const [
classes_homeroom_teacher,
class_subjects_teacher,
attendance_sessions_taken_by,
organization,
campus,
user,
photo,
] = await Promise.all([
staff.getClasses_homeroom_teacher({ transaction }),
staff.getClass_subjects_teacher({ transaction }),
staff.getAttendance_sessions_taken_by({ transaction }),
staff.getOrganization({ transaction }),
staff.getCampus({ transaction }),
staff.getUser({ transaction }),
staff.getPhoto({ transaction }),
]);
output.classes_homeroom_teacher = classes_homeroom_teacher;
output.class_subjects_teacher = class_subjects_teacher;
output.attendance_sessions_taken_by = attendance_sessions_taken_by;
output.organization = organization;
output.campus = campus;
output.user = user;
output.photo = photo;
return output;
}
static async findAll(
filter: StaffFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Staff[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.campuses,
as: 'campus',
where: filter.campus
? {
[Op.or]: [
{
id: {
[Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.campus
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{
model: db.users,
as: 'user',
where: filter.user
? {
[Op.or]: [
{
id: {
[Op.in]: filter.user.split('|').map((t) => Utils.uuid(t)),
},
},
{
firstName: {
[Op.or]: filter.user
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{ model: db.file, as: 'photo' },
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.employee_number) {
where = {
...where,
[Op.and]: Utils.ilike('staff', 'employee_number', filter.employee_number),
};
}
if (filter.job_title) {
where = {
...where,
[Op.and]: Utils.ilike('staff', 'job_title', filter.job_title),
};
}
if (filter.hire_dateRange) {
const [start, end] = filter.hire_dateRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, hire_date: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
hire_date: {
...(typeof where.hire_date === 'object' ? where.hire_date : {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.staff_type) {
where = { ...where, staff_type: filter.staff_type };
}
if (filter.status) {
where = { ...where, status: filter.status };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.staff.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.staff,
'employee_number',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default StaffDBApi;

View File

@ -1,10 +1,12 @@
import type { InferAttributes, Transaction } from 'sequelize'; import type { InferAttributes, Transaction } from 'sequelize';
import type { Users } from '@/db/models/users'; import type { Users } from '@/db/models/users';
import type { Staff } from '@/db/models/staff';
import type { Roles } from '@/db/models/roles'; import type { Roles } from '@/db/models/roles';
import type { Permissions } from '@/db/models/permissions'; import type { Permissions } from '@/db/models/permissions';
import type { Organizations } from '@/db/models/organizations'; import type { Organizations } from '@/db/models/organizations';
import type { Campuses } from '@/db/models/campuses'; import type { Campuses } from '@/db/models/campuses';
import type { Schools } from '@/db/models/schools';
import type { Classes } from '@/db/models/classes';
import type { File } from '@/db/models/file';
/** A permission record, reduced to the fields consumers read. */ /** A permission record, reduced to the fields consumers read. */
export interface PermissionLike { export interface PermissionLike {
@ -23,13 +25,20 @@ export interface UserProfileRecord {
name_prefix: string | null; name_prefix: string | null;
firstName: string | null; firstName: string | null;
lastName: string | null; lastName: string | null;
phoneNumber: string | null;
organizationId: string | null; organizationId: string | null;
schoolId: string | null;
campusId: string | null;
classId: string | null;
organizations: Organizations | null; organizations: Organizations | null;
school: Schools | null;
campus: Campuses | null;
class: Classes | null;
app_role: Roles | null; app_role: Roles | null;
app_role_permissions: Permissions[]; app_role_permissions: Permissions[];
custom_permissions: Permissions[]; custom_permissions: Permissions[];
staff_user: Staff[]; custom_permissions_filter: Permissions[];
staff_campus: Campuses | null; avatar: File[];
} }
/** /**
@ -38,11 +47,13 @@ export interface UserProfileRecord {
* {@link CurrentUser}, so it is assignable to `req.currentUser` without a cast. * {@link CurrentUser}, so it is assignable to `req.currentUser` without a cast.
*/ */
export type AuthenticatedUser = InferAttributes<Users> & { export type AuthenticatedUser = InferAttributes<Users> & {
staff_user: Staff[];
app_role: Roles | null; app_role: Roles | null;
app_role_permissions: Permissions[]; app_role_permissions: Permissions[];
custom_permissions: Permissions[]; custom_permissions: Permissions[];
custom_permissions_filter: Permissions[];
organizations: Organizations | null; organizations: Organizations | null;
/** Drill-down override resolved by the active-scope middleware. */
activeScope?: CurrentUser['activeScope'];
}; };
/** Minimal shape of the authenticated user passed through the data layer. */ /** Minimal shape of the authenticated user passed through the data layer. */
@ -57,6 +68,8 @@ export interface CurrentUser {
scope?: string | null; scope?: string | null;
/** Present on the loaded role instance attached to the request. */ /** Present on the loaded role instance attached to the request. */
getPermissions?: () => Promise<PermissionLike[]>; getPermissions?: () => Promise<PermissionLike[]>;
/** Present when role permissions are eager-loaded with the request user. */
permissions?: PermissionLike[] | null;
} | null; } | null;
/** /**
* Present when the value is the full authenticated user record attached to * Present when the value is the full authenticated user record attached to
@ -65,20 +78,30 @@ export interface CurrentUser {
*/ */
password?: string | null; password?: string | null;
custom_permissions?: PermissionLike[] | null; custom_permissions?: PermissionLike[] | null;
custom_permissions_filter?: PermissionLike[] | null;
name_prefix?: string | null; name_prefix?: string | null;
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
phoneNumber?: string | null;
email?: string | null; email?: string | null;
campusId?: string | null; campusId?: string | null;
campus?: { code?: string | null; name?: string | null } | null; campus?: { code?: string | null; name?: string | null } | null;
schoolId?: string | null; schoolId?: string | null;
school?: { id?: string | null; name?: string | null } | null; school?: { id?: string | null; name?: string | null } | null;
staff_user?: Array<{ classId?: string | null;
campusId?: string | null; class?: { id?: string | null; name?: string | null } | null;
schoolId?: string | null; /**
staff_type?: string | null; * Drill-down override: when set (resolved + validated from the active-tenant
campus?: { code?: string | null; name?: string | null } | null; * request header), scope resolution acts as this tenant instead of the user's
}> | null; * own. The full chain is resolved so leaf-getters return the right ids.
*/
activeScope?: {
level: 'organization' | 'school' | 'campus' | 'class';
organizationId: string | null;
schoolId: string | null;
campusId: string | null;
classId: string | null;
} | null;
} }
/** Common options accepted by db/api methods. */ /** Common options accepted by db/api methods. */

View File

@ -0,0 +1,157 @@
import { afterEach, mock, test } from 'node:test';
import assert from 'node:assert/strict';
import { inspect } from 'node:util';
import { Op, type FindAndCountOptions } from 'sequelize';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users';
afterEach(() => {
mock.restoreAll();
});
test('UsersDBApi query search uses correlated subqueries for related names', async () => {
let capturedOptions: FindAndCountOptions | null = null;
mock.method(
db.users,
'findAndCountAll',
(async (options: FindAndCountOptions) => {
capturedOptions = options;
return { rows: [], count: 0 };
}) as unknown as typeof db.users.findAndCountAll,
);
await UsersDBApi.findAll({ query: 'John', limit: 10, page: 0 }, true, {});
assert.ok(capturedOptions, 'expected findAndCountAll to be called');
const options = capturedOptions as FindAndCountOptions;
const where = options.where as Record<string | symbol, unknown>;
const andConditions = where[Op.and];
assert.ok(Array.isArray(andConditions), 'expected query search conditions in Op.and');
const serializedWhere = inspect(where, { depth: 10 });
assert.ok(
serializedWhere.includes('FROM "organizations"'),
'expected organization name search to use a correlated subquery',
);
assert.ok(
serializedWhere.includes('FROM "schools"'),
'expected school name search to use a correlated subquery',
);
assert.ok(
serializedWhere.includes('FROM "campuses"'),
'expected campus name search to use a correlated subquery',
);
assert.ok(
serializedWhere.includes('FROM "classes"'),
'expected class name search to use a correlated subquery',
);
assert.ok(
serializedWhere.includes('FROM "roles"'),
'expected role name search to use a correlated subquery',
);
assert.equal(
serializedWhere.includes('lower("school"."name")')
|| serializedWhere.includes('lower("campus"."name")')
|| serializedWhere.includes('lower("class"."name")')
|| serializedWhere.includes('lower("app_role"."name")'),
false,
'expected no direct joined-alias name references in the where clause',
);
});
test('UsersDBApi ignores removed user filters', async () => {
let capturedOptions: FindAndCountOptions | null = null;
mock.method(
db.users,
'findAndCountAll',
(async (options: FindAndCountOptions) => {
capturedOptions = options;
return { rows: [], count: 0 };
}) as unknown as typeof db.users.findAndCountAll,
);
const removedFilter = {
active: 'true',
password: 'secret',
emailVerificationToken: 'verify-token',
passwordResetToken: 'reset-token',
disabled: 'false',
limit: 10,
page: 0,
};
await UsersDBApi.findAll(removedFilter, true, {});
assert.ok(capturedOptions, 'expected findAndCountAll to be called');
const options = capturedOptions as FindAndCountOptions;
const where = options.where as Record<string | symbol, unknown>;
const serializedWhere = inspect(where, { depth: 10 });
assert.equal(where.disabled, false);
assert.equal(serializedWhere.includes('active'), false);
assert.equal(serializedWhere.includes('password'), false);
assert.equal(serializedWhere.includes('emailVerificationToken'), false);
assert.equal(serializedWhere.includes('passwordResetToken'), false);
});
test('UsersDBApi campus filter includes class-scoped users inside that campus', async () => {
let capturedOptions: FindAndCountOptions | null = null;
mock.method(
db.users,
'findAndCountAll',
(async (options: FindAndCountOptions) => {
capturedOptions = options;
return { rows: [], count: 0 };
}) as unknown as typeof db.users.findAndCountAll,
);
await UsersDBApi.findAll(
{ campusId: '00000000-0000-4000-8000-000000000001', limit: 10, page: 0 },
true,
{},
);
assert.ok(capturedOptions, 'expected findAndCountAll to be called');
const options = capturedOptions as FindAndCountOptions;
const where = options.where as Record<string | symbol, unknown>;
const serializedWhere = inspect(where, { depth: 10 });
assert.ok(
serializedWhere.includes('FROM "classes" WHERE "campusId"'),
'expected campus filtering to include class-scoped users',
);
});
test('UsersDBApi class filter includes enrolled students', async () => {
let capturedOptions: FindAndCountOptions | null = null;
mock.method(
db.users,
'findAndCountAll',
(async (options: FindAndCountOptions) => {
capturedOptions = options;
return { rows: [], count: 0 };
}) as unknown as typeof db.users.findAndCountAll,
);
await UsersDBApi.findAll(
{ classId: '00000000-0000-4000-8000-000000000002', limit: 10, page: 0 },
true,
{},
);
assert.ok(capturedOptions, 'expected findAndCountAll to be called');
const options = capturedOptions as FindAndCountOptions;
const where = options.where as Record<string | symbol, unknown>;
const serializedWhere = inspect(where, { depth: 10 });
assert.ok(
serializedWhere.includes('FROM "class_enrollments"'),
'expected class filtering to include enrolled students',
);
});

View File

@ -1,9 +1,12 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { import {
Op, Op,
literal,
col,
type Includeable, type Includeable,
type InferAttributes, type InferAttributes,
type InferCreationAttributes, type InferCreationAttributes,
type OrderItem,
type WhereAttributeHash, type WhereAttributeHash,
} from 'sequelize'; } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
@ -35,6 +38,8 @@ type UsersInputData = Partial<InferCreationAttributes<Users>> & {
app_role?: string | null; app_role?: string | null;
organizations?: string | null; organizations?: string | null;
custom_permissions?: string[]; custom_permissions?: string[];
/** Permissions explicitly removed from the user (subtracted from the role). */
custom_permissions_filter?: string[];
avatar?: FileInput | FileInput[] | null; avatar?: FileInput | FileInput[] | null;
}; };
@ -50,21 +55,18 @@ type DateRange = Array<string | null | undefined>;
interface UsersFilter { interface UsersFilter {
limit?: number | string; limit?: number | string;
page?: number | string; page?: number | string;
query?: string;
id?: string; id?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
phoneNumber?: string; phoneNumber?: string;
email?: string; email?: string;
password?: string;
emailVerificationToken?: string;
passwordResetToken?: string;
provider?: string; provider?: string;
emailVerificationTokenExpiresAtRange?: DateRange;
passwordResetTokenExpiresAtRange?: DateRange;
active?: boolean | string;
disabled?: boolean | string; disabled?: boolean | string;
emailVerified?: boolean | string; emailVerified?: boolean | string;
app_role?: string; app_role?: string;
campusId?: string;
classId?: string;
organizations?: string; organizations?: string;
custom_permissions?: string; custom_permissions?: string;
createdAtRange?: DateRange; createdAtRange?: DateRange;
@ -72,13 +74,243 @@ interface UsersFilter {
sort?: string; sort?: string;
} }
type UserListSortField =
| 'name'
| 'email'
| 'phoneNumber'
| 'organization'
| 'school'
| 'campus'
| 'role';
const NO_USER: CurrentUser = { id: null }; const NO_USER: CurrentUser = { id: null };
const UUID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function usersTableName(): string { function usersTableName(): string {
const name = db.users.getTableName(); const name = db.users.getTableName();
return typeof name === 'string' ? name : name.tableName; return typeof name === 'string' ? name : name.tableName;
} }
function currentScope(currentUser?: CurrentUser): {
level: string | null;
organizationId: string | null;
schoolId: string | null;
campusId: string | null;
classId: string | null;
global: boolean;
} {
if (currentUser?.activeScope) {
return {
level: currentUser.activeScope.level,
organizationId: currentUser.activeScope.organizationId,
schoolId: currentUser.activeScope.schoolId,
campusId: currentUser.activeScope.campusId,
classId: currentUser.activeScope.classId,
global: false,
};
}
return {
level: currentUser?.app_role?.scope ?? null,
organizationId: currentUser?.organizations?.id || currentUser?.organizationId || null,
schoolId: currentUser?.schoolId ?? null,
campusId: currentUser?.campusId ?? null,
classId: currentUser?.classId ?? null,
global: currentUser?.app_role?.globalAccess === true,
};
}
function scopedUsersWhere(currentUser?: CurrentUser): WhereAttributeHash {
const scope = currentScope(currentUser);
if (scope.global) {
return {};
}
if (scope.level === 'organization' && scope.organizationId) {
return { organizationId: scope.organizationId };
}
if (scope.level === 'school' && scope.schoolId) {
if (!UUID_RE.test(scope.schoolId)) return {};
return {
[Op.or]: [
{ schoolId: scope.schoolId },
{
campusId: {
[Op.in]: literal(
`(SELECT "id" FROM "campuses" WHERE "schoolId" = '${scope.schoolId}' AND "deletedAt" IS NULL)`,
),
},
},
{
classId: {
[Op.in]: literal(
`(SELECT "c"."id" FROM "classes" "c" JOIN "campuses" "cm" ON "cm"."id" = "c"."campusId" WHERE "cm"."schoolId" = '${scope.schoolId}' AND "c"."deletedAt" IS NULL AND "cm"."deletedAt" IS NULL)`,
),
},
},
],
};
}
if (scope.level === 'campus' && scope.campusId) {
if (!UUID_RE.test(scope.campusId)) return {};
return {
[Op.or]: [
{ campusId: scope.campusId },
{
classId: {
[Op.in]: literal(
`(SELECT "id" FROM "classes" WHERE "campusId" = '${scope.campusId}' AND "deletedAt" IS NULL)`,
),
},
},
],
};
}
if (scope.level === 'class' && scope.classId) {
const classId = db.sequelize.escape(scope.classId);
return {
[Op.or]: [
{ classId: scope.classId },
{
id: {
[Op.in]: literal(
`(SELECT "studentId" FROM "class_enrollments" WHERE "classId" = ${classId} AND "deletedAt" IS NULL)`,
),
},
},
],
};
}
if (scope.organizationId) {
return { organizationId: scope.organizationId };
}
return {};
}
function appendAndCondition(
where: WhereAttributeHash,
condition: unknown,
): WhereAttributeHash {
const currentAnd = (where as Record<symbol, unknown>)[Op.and];
const existingAnd = Array.isArray(currentAnd)
? currentAnd
: currentAnd
? [currentAnd]
: [];
return {
...where,
[Op.and]: [...existingAnd, condition],
};
}
function relatedNameExistsCondition(
tableName: string,
foreignKey: keyof Pick<
UsersFilter,
never
> | 'organizationId' | 'schoolId' | 'campusId' | 'classId' | 'app_roleId',
value: string,
) {
const escapedPattern = db.sequelize.escape(`%${value.toLowerCase()}%`);
const usersTable = usersTableName();
return literal(
`EXISTS (
SELECT 1
FROM "${tableName}"
WHERE "${tableName}"."id" = "${usersTable}"."${foreignKey}"
AND "${tableName}"."deletedAt" IS NULL
AND lower("${tableName}"."name") LIKE ${escapedPattern}
)`,
);
}
function userSearchTokenCondition(token: string) {
return {
[Op.or]: [
Utils.ilike('users', 'firstName', token),
Utils.ilike('users', 'lastName', token),
Utils.ilike('users', 'email', token),
Utils.ilike('users', 'phoneNumber', token),
relatedNameExistsCondition('organizations', 'organizationId', token),
relatedNameExistsCondition('schools', 'schoolId', token),
relatedNameExistsCondition('campuses', 'campusId', token),
relatedNameExistsCondition('classes', 'classId', token),
relatedNameExistsCondition('roles', 'app_roleId', token),
],
};
}
function parseUserSortDirection(value: unknown): 'ASC' | 'DESC' {
return String(value).toLowerCase() === 'asc' ? 'ASC' : 'DESC';
}
function parseUserSortField(value: unknown): UserListSortField | null {
switch (value) {
case 'name':
case 'email':
case 'phoneNumber':
case 'organization':
case 'school':
case 'campus':
case 'role':
return value;
default:
return null;
}
}
function userListOrder(field: unknown, sort: unknown): OrderItem[] {
const direction = parseUserSortDirection(sort);
const parsedField = parseUserSortField(field);
switch (parsedField) {
case 'name':
return [
[col('users.lastName'), direction],
[col('users.firstName'), direction],
[col('users.email'), direction],
];
case 'email':
return [[col('users.email'), direction]];
case 'phoneNumber':
return [
[col('users.phoneNumber'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'organization':
return [
[col('organizations.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'school':
return [
[col('school.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'campus':
return [
[col('campus.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
case 'role':
return [
[col('app_role.name'), direction],
[col('users.lastName'), 'ASC'],
[col('users.firstName'), 'ASC'],
];
default:
return [['createdAt', 'desc']];
}
}
/** Email is a user's login and primary contact, so it is always required. */ /** Email is a user's login and primary contact, so it is always required. */
function requireEmail(email: string | null | undefined): string { function requireEmail(email: string | null | undefined): string {
if (!email) { if (!email) {
@ -114,8 +346,9 @@ class UsersDBApi {
passwordResetToken: data.passwordResetToken || null, passwordResetToken: data.passwordResetToken || null,
passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null, passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null,
provider: data.provider || null, provider: data.provider || null,
importHash: data.importHash || null,
campusId: data.campusId || null, campusId: data.campusId || null,
schoolId: data.schoolId || null,
classId: data.classId || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
}, },
@ -134,6 +367,10 @@ class UsersDBApi {
await users.setCustom_permissions(data.custom_permissions || [], { await users.setCustom_permissions(data.custom_permissions || [], {
transaction, transaction,
}); });
await users.setCustom_permissions_filter(
data.custom_permissions_filter || [],
{ transaction },
);
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
@ -171,7 +408,6 @@ class UsersDBApi {
passwordResetToken: item.passwordResetToken || null, passwordResetToken: item.passwordResetToken || null,
passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null,
provider: item.provider || null, provider: item.provider || null,
importHash: item.importHash || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
@ -234,6 +470,8 @@ class UsersDBApi {
data.passwordResetTokenExpiresAt; data.passwordResetTokenExpiresAt;
if (data.provider !== undefined) updatePayload.provider = data.provider; if (data.provider !== undefined) updatePayload.provider = data.provider;
if (data.campusId !== undefined) updatePayload.campusId = data.campusId; if (data.campusId !== undefined) updatePayload.campusId = data.campusId;
if (data.schoolId !== undefined) updatePayload.schoolId = data.schoolId;
if (data.classId !== undefined) updatePayload.classId = data.classId;
updatePayload.updatedById = currentUser.id; updatePayload.updatedById = currentUser.id;
@ -252,6 +490,11 @@ class UsersDBApi {
transaction, transaction,
}); });
} }
if (data.custom_permissions_filter !== undefined) {
await users.setCustom_permissions_filter(data.custom_permissions_filter, {
transaction,
});
}
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
@ -287,7 +530,7 @@ class UsersDBApi {
const transaction = options?.transaction; const transaction = options?.transaction;
// Per-request auth/session load. Authorization needs only role (+its // Per-request auth/session load. Authorization needs only role (+its
// permissions), per-user permissions, staff profile, and org — loaded in a // permissions), per-user permissions, and org — loaded in a
// single eager query (no per-association getter round-trips). `app_role` // single eager query (no per-association getter round-trips). `app_role`
// carries its `permissions`, so the permission middleware reads them off // carries its `permissions`, so the permission middleware reads them off
// the loaded array instead of issuing another `getPermissions()` query. // the loaded array instead of issuing another `getPermissions()` query.
@ -306,12 +549,16 @@ class UsersDBApi {
}, },
], ],
}, },
{ model: db.staff, as: 'staff_user' },
{ {
model: db.permissions, model: db.permissions,
as: 'custom_permissions', as: 'custom_permissions',
through: { attributes: [] }, through: { attributes: [] },
}, },
{
model: db.permissions,
as: 'custom_permissions_filter',
through: { attributes: [] },
},
{ model: db.organizations, as: 'organizations' }, { model: db.organizations, as: 'organizations' },
], ],
transaction, transaction,
@ -323,14 +570,28 @@ class UsersDBApi {
return { return {
...users.get({ plain: true }), ...users.get({ plain: true }),
staff_user: users.staff_user ?? [],
app_role: users.app_role ?? null, app_role: users.app_role ?? null,
app_role_permissions: users.app_role?.permissions ?? [], app_role_permissions: users.app_role?.permissions ?? [],
custom_permissions: users.custom_permissions ?? [], custom_permissions: users.custom_permissions ?? [],
custom_permissions_filter: users.custom_permissions_filter ?? [],
organizations: users.organizations ?? null, organizations: users.organizations ?? null,
}; };
} }
static async findOrCreateSocialIdentity(
email: string,
provider: string,
options?: DbApiOptions,
): Promise<Users> {
const transaction = options?.transaction;
const [user] = await db.users.findOrCreate({
where: { email, provider },
transaction,
});
return user;
}
/** /**
* Trimmed profile fetch for `GET /me` (and the signin/refresh responses): * Trimmed profile fetch for `GET /me` (and the signin/refresh responses):
* one eager-loaded query selecting only the columns and relations the * one eager-loaded query selecting only the columns and relations the
@ -350,14 +611,18 @@ class UsersDBApi {
'email', 'email',
'firstName', 'firstName',
'lastName', 'lastName',
'phoneNumber',
'organizationId', 'organizationId',
'schoolId',
'campusId',
'classId',
'app_roleId', 'app_roleId',
], ],
include: [ include: [
{ {
model: db.roles, model: db.roles,
as: 'app_role', as: 'app_role',
attributes: ['id', 'name', 'globalAccess'], attributes: ['id', 'name', 'scope', 'globalAccess'],
include: [ include: [
{ {
model: db.permissions, model: db.permissions,
@ -374,30 +639,35 @@ class UsersDBApi {
through: { attributes: [] }, through: { attributes: [] },
}, },
{ {
model: db.organizations, model: db.permissions,
as: 'organizations', as: 'custom_permissions_filter',
attributes: ['id', 'name'], attributes: ['id', 'name'],
through: { attributes: [] },
}, },
{ {
model: db.staff, model: db.organizations,
as: 'staff_user', as: 'organizations',
attributes: [ attributes: ['id', 'name', 'logo'],
'id', },
'employee_number', {
'job_title', model: db.schools,
'staff_type', as: 'school',
'status', attributes: ['id', 'name', 'logo'],
'organizationId', },
'campusId', {
'userId', model: db.campuses,
], as: 'campus',
include: [ attributes: ['id', 'name', 'code', 'logo'],
{ },
model: db.campuses, {
as: 'campus', model: db.classes,
attributes: ['id', 'name', 'code'], as: 'class',
}, attributes: ['id', 'name', 'logo'],
], },
{
model: db.file,
as: 'avatar',
attributes: ['id', 'name', 'privateUrl', 'publicUrl'],
}, },
], ],
transaction, transaction,
@ -407,21 +677,26 @@ class UsersDBApi {
return null; return null;
} }
const staffProfile = user.staff_user?.[0] ?? null;
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name_prefix: user.name_prefix ?? null, name_prefix: user.name_prefix ?? null,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
phoneNumber: user.phoneNumber ?? null,
organizationId: user.organizationId, organizationId: user.organizationId,
schoolId: user.schoolId ?? null,
campusId: user.campusId ?? null,
classId: user.classId ?? null,
organizations: user.organizations ?? null, organizations: user.organizations ?? null,
school: user.school ?? null,
campus: user.campus ?? null,
class: user.class ?? null,
app_role: user.app_role ?? null, app_role: user.app_role ?? null,
app_role_permissions: user.app_role?.permissions ?? [], app_role_permissions: user.app_role?.permissions ?? [],
custom_permissions: user.custom_permissions ?? [], custom_permissions: user.custom_permissions ?? [],
staff_user: user.staff_user ?? [], custom_permissions_filter: user.custom_permissions_filter ?? [],
staff_campus: staffProfile?.campus ?? null, avatar: user.avatar ?? [],
}; };
} }
@ -432,12 +707,7 @@ class UsersDBApi {
): Promise<{ rows: Users[]; count: number }> { ): Promise<{ rows: Users[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page); const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {}; let where: WhereAttributeHash = scopedUsersWhere(options?.currentUser);
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
let include: Includeable[] = [ let include: Includeable[] = [
{ {
@ -465,118 +735,101 @@ class UsersDBApi {
: {}, : {},
}, },
{ model: db.organizations, as: 'organizations' }, { model: db.organizations, as: 'organizations' },
{ model: db.schools, as: 'school', required: false },
{ model: db.campuses, as: 'campus', required: false },
{ model: db.classes, as: 'class', required: false },
{ model: db.permissions, as: 'custom_permissions', required: false }, { model: db.permissions, as: 'custom_permissions', required: false },
{ model: db.permissions, as: 'custom_permissions_filter', required: false },
{ model: db.file, as: 'avatar' }, { model: db.file, as: 'avatar' },
]; ];
if (filter.id) { if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) }; where = { ...where, id: Utils.uuid(filter.id) };
} }
if (filter.classId) {
const classId = Utils.uuid(filter.classId);
where = appendAndCondition(where, {
[Op.or]: [
{ classId },
{
id: {
[Op.in]: literal(
`(SELECT "studentId" FROM "class_enrollments" WHERE "classId" = ${db.sequelize.escape(classId)} AND "deletedAt" IS NULL)`,
),
},
},
],
});
}
if (filter.campusId) {
const campusId = Utils.uuid(filter.campusId);
where = appendAndCondition(where, {
[Op.or]: [
{ campusId },
{
classId: {
[Op.in]: literal(
`(SELECT "id" FROM "classes" WHERE "campusId" = ${db.sequelize.escape(campusId)} AND "deletedAt" IS NULL)`,
),
},
},
],
});
}
if (filter.firstName) { if (filter.firstName) {
where = { where = appendAndCondition(
...where, where,
[Op.and]: Utils.ilike('users', 'firstName', filter.firstName), Utils.ilike('users', 'firstName', filter.firstName),
}; );
} }
if (filter.lastName) { if (filter.lastName) {
where = { where = appendAndCondition(
...where, where,
[Op.and]: Utils.ilike('users', 'lastName', filter.lastName), Utils.ilike('users', 'lastName', filter.lastName),
}; );
} }
if (filter.phoneNumber) { if (filter.phoneNumber) {
where = { where = appendAndCondition(
...where, where,
[Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber), Utils.ilike('users', 'phoneNumber', filter.phoneNumber),
}; );
} }
if (filter.email) { if (filter.email) {
where = { where = appendAndCondition(
...where, where,
[Op.and]: Utils.ilike('users', 'email', filter.email), Utils.ilike('users', 'email', filter.email),
}; );
}
if (filter.password) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'password', filter.password),
};
}
if (filter.emailVerificationToken) {
where = {
...where,
[Op.and]: Utils.ilike(
'users',
'emailVerificationToken',
filter.emailVerificationToken,
),
};
}
if (filter.passwordResetToken) {
where = {
...where,
[Op.and]: Utils.ilike(
'users',
'passwordResetToken',
filter.passwordResetToken,
),
};
} }
if (filter.provider) { if (filter.provider) {
where = appendAndCondition(
where,
Utils.ilike('users', 'provider', filter.provider),
);
}
if (filter.query) {
const tokens = filter.query
.trim()
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
for (const token of tokens) {
where = appendAndCondition(where, userSearchTokenCondition(token));
}
}
if (filter.disabled !== undefined) {
where = { where = {
...where, ...where,
[Op.and]: Utils.ilike('users', 'provider', filter.provider), disabled: filter.disabled === true || filter.disabled === 'true',
}; };
} }
if (filter.emailVerificationTokenExpiresAtRange) { if (filter.emailVerified !== undefined) {
const [start, end] = filter.emailVerificationTokenExpiresAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
emailVerificationTokenExpiresAt: { [Op.gte]: start },
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
emailVerificationTokenExpiresAt: {
...(typeof where.emailVerificationTokenExpiresAt === 'object'
? where.emailVerificationTokenExpiresAt
: {}),
[Op.lte]: end,
},
};
}
}
if (filter.passwordResetTokenExpiresAtRange) {
const [start, end] = filter.passwordResetTokenExpiresAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, passwordResetTokenExpiresAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
passwordResetTokenExpiresAt: {
...(typeof where.passwordResetTokenExpiresAt === 'object'
? where.passwordResetTokenExpiresAt
: {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = { where = {
...where, ...where,
active: filter.active === true || filter.active === 'true', emailVerified:
filter.emailVerified === true || filter.emailVerified === 'true',
}; };
} }
if (filter.disabled) {
where = { ...where, disabled: filter.disabled };
}
if (filter.emailVerified) {
where = { ...where, emailVerified: filter.emailVerified };
}
if (filter.organizations) { if (filter.organizations) {
const listItems = filter.organizations const listItems = filter.organizations
.split('|') .split('|')
@ -625,14 +878,11 @@ class UsersDBApi {
} }
} }
if (globalAccess) { if (globalAccess && !options?.currentUser?.activeScope) {
delete where.organizationId; delete where.organizationId;
} }
const order: [string, string][] = const order = userListOrder(filter.field, filter.sort);
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.users.findAndCountAll({ const { rows, count } = await db.users.findAndCountAll({
where, where,

View File

@ -1,7 +1,7 @@
// AUTO-GENERATED schema snapshot from the Sequelize models. // AUTO-GENERATED schema snapshot from the Sequelize models.
// Source for the initial migration (see migrations/*-initial-schema.ts). // Source for the initial migration (see migrations/*-initial-schema.ts).
export const INITIAL_SCHEMA_UP = ` export const INITIAL_SCHEMA_UP = `
CREATE TABLE IF NOT EXISTS "users" ("id" UUID , "firstName" TEXT, "lastName" TEXT, "phoneNumber" TEXT, "email" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT false, "password" TEXT, "emailVerified" BOOLEAN NOT NULL DEFAULT false, "emailVerificationToken" TEXT, "emailVerificationTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "passwordResetToken" TEXT, "passwordResetTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "provider" TEXT, "importHash" VARCHAR(255) UNIQUE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "app_roleId" UUID, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "users" ("id" UUID , "firstName" TEXT, "lastName" TEXT, "phoneNumber" TEXT, "email" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT false, "password" TEXT, "emailVerified" BOOLEAN NOT NULL DEFAULT false, "emailVerificationToken" TEXT, "emailVerificationTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "passwordResetToken" TEXT, "passwordResetTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "provider" TEXT, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "app_roleId" UUID, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "academic_years" ("id" UUID , "name" TEXT, "start_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE, "current" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "academic_years" ("id" UUID , "name" TEXT, "start_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE, "current" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_assessment_results_grade_letter" AS ENUM(''A'', ''B'', ''C'', ''D'', ''E'', ''F'', ''P'', ''N''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_assessment_results_grade_letter" AS ENUM(''A'', ''B'', ''C'', ''D'', ''E'', ''F'', ''P'', ''N''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "assessment_results" ("id" UUID , "score" DECIMAL, "grade_letter" "public"."enum_assessment_results_grade_letter", "remarks" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "assessmentId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "assessment_results" ("id" UUID , "score" DECIMAL, "grade_letter" "public"."enum_assessment_results_grade_letter", "remarks" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "assessmentId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS "class_subjects" ("id" UUID , "status" "public"."enum
DO 'BEGIN CREATE TYPE "public"."enum_classes_status" AS ENUM(''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_classes_status" AS ENUM(''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, "capacity" INTEGER, "status" "public"."enum_classes_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "gradeId" UUID, "homeroom_teacherId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, "capacity" INTEGER, "status" "public"."enum_classes_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "gradeId" UUID, "homeroom_teacherId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_communication_events_event_type" AS ENUM(''meeting'', ''drill'', ''event'', ''deadline''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_communication_events_event_type" AS ENUM(''meeting'', ''drill'', ''event'', ''deadline''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "targetLevel" TEXT NOT NULL DEFAULT 'campus', "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "schoolId" UUID, "classId" UUID, "canceledEventId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "content_catalog" ("id" UUID , "content_type" TEXT NOT NULL UNIQUE, "payload" JSONB NOT NULL, "active" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "content_catalog" ("id" UUID , "content_type" TEXT NOT NULL UNIQUE, "payload" JSONB NOT NULL, "active" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "files" ("id" UUID , "belongsTo" VARCHAR(255), "belongsToId" UUID, "belongsToColumn" VARCHAR(255), "name" VARCHAR(2083) NOT NULL, "sizeInBytes" INTEGER, "privateUrl" VARCHAR(2083), "publicUrl" VARCHAR(2083) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "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 "frame_entries" ("id" UUID , "week_of" TEXT NOT NULL, "posted_date" TEXT NOT NULL, "formal" TEXT NOT NULL, "recognition" TEXT NOT NULL, "application" TEXT NOT NULL, "management" TEXT NOT NULL, "emotional" TEXT NOT NULL, "author" TEXT NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -40,9 +40,6 @@ CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash"
CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_staff_staff_type" AS ENUM(''teacher'', ''admin'', ''support''); EXCEPTION WHEN duplicate_object THEN null; END';
DO 'BEGIN CREATE TYPE "public"."enum_staff_status" AS ENUM(''active'', ''on_leave'', ''inactive''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "staff" ("id" UUID , "employee_number" TEXT, "job_title" TEXT, "staff_type" "public"."enum_staff_staff_type", "hire_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_staff_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "staff_attendance_records" ("id" UUID , "attendance_date" DATE NOT NULL, "status" "public"."enum_staff_attendance_records_status" NOT NULL, "note" TEXT, "user_name" TEXT NOT NULL, "user_role" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "staff_attendance_records" ("id" UUID , "attendance_date" DATE NOT NULL, "status" "public"."enum_staff_attendance_records_status" NOT NULL, "note" TEXT, "user_name" TEXT NOT NULL, "user_role" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "subjects" ("id" UUID , "name" TEXT, "code" TEXT, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "subjects" ("id" UUID , "name" TEXT, "code" TEXT, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -50,7 +47,7 @@ DO 'BEGIN CREATE TYPE "public"."enum_timetable_periods_day_of_week" AS ENUM(''mo
CREATE TABLE IF NOT EXISTS "timetable_periods" ("id" UUID , "day_of_week" "public"."enum_timetable_periods_day_of_week", "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "room" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "class_subjectId" UUID, "organizationId" UUID, "timetableId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "timetable_periods" ("id" UUID , "day_of_week" "public"."enum_timetable_periods_day_of_week", "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "room" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "class_subjectId" UUID, "organizationId" UUID, "timetableId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_timetables_status" AS ENUM(''draft'', ''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_timetables_status" AS ENUM(''draft'', ''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "timetables" ("id" UUID , "name" TEXT, "effective_from" TIMESTAMP WITH TIME ZONE, "effective_to" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_timetables_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "timetables" ("id" UUID , "name" TEXT, "effective_from" TIMESTAMP WITH TIME ZONE, "effective_to" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_timetables_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_user_progress_progress_type" AS ENUM(''sign_learned'', ''zone_checkin''); EXCEPTION WHEN duplicate_object THEN null; END'; DO 'BEGIN CREATE TYPE "public"."enum_user_progress_progress_type" AS ENUM(''sign_learned'', ''zone_checkin'', ''classroom_strategy_favorite''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "user_progress" ("id" UUID , "progress_type" "public"."enum_user_progress_progress_type" NOT NULL, "item_id" TEXT NOT NULL, "value" TEXT, "score" INTEGER, "metadata" JSONB, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "user_progress" ("id" UUID , "progress_type" "public"."enum_user_progress_progress_type" NOT NULL, "item_id" TEXT NOT NULL, "value" TEXT, "score" INTEGER, "metadata" JSONB, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "walkthrough_checkins" ("id" UUID , "teacher_name" TEXT NOT NULL, "classroom" TEXT NOT NULL, "director_name" TEXT NOT NULL, "check_in_date" DATE NOT NULL, "check_in_time" TIME NOT NULL, "attitude_rating" INTEGER NOT NULL, "attitude_comment" TEXT, "classroom_management_rating" INTEGER NOT NULL, "classroom_management_comment" TEXT, "cleanliness_rating" INTEGER NOT NULL, "cleanliness_comment" TEXT, "vibes_rating" INTEGER NOT NULL, "vibes_comment" TEXT, "team_dynamics_rating" INTEGER NOT NULL, "team_dynamics_comment" TEXT, "emergency_exit_rating" INTEGER NOT NULL, "emergency_exit_comment" TEXT, "lesson_plan_rating" INTEGER NOT NULL, "lesson_plan_comment" TEXT, "overall_notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); CREATE TABLE IF NOT EXISTS "walkthrough_checkins" ("id" UUID , "teacher_name" TEXT NOT NULL, "classroom" TEXT NOT NULL, "director_name" TEXT NOT NULL, "check_in_date" DATE NOT NULL, "check_in_time" TIME NOT NULL, "attitude_rating" INTEGER NOT NULL, "attitude_comment" TEXT, "classroom_management_rating" INTEGER NOT NULL, "classroom_management_comment" TEXT, "cleanliness_rating" INTEGER NOT NULL, "cleanliness_comment" TEXT, "vibes_rating" INTEGER NOT NULL, "vibes_comment" TEXT, "team_dynamics_rating" INTEGER NOT NULL, "team_dynamics_comment" TEXT, "emergency_exit_rating" INTEGER NOT NULL, "emergency_exit_comment" TEXT, "lesson_plan_rating" INTEGER NOT NULL, "lesson_plan_comment" TEXT, "overall_notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "roles_permissionsId" UUID , "permissionId" UUID , PRIMARY KEY ("roles_permissionsId","permissionId")); CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "roles_permissionsId" UUID , "permissionId" UUID , PRIMARY KEY ("roles_permissionsId","permissionId"));
@ -66,7 +63,6 @@ DROP TABLE IF EXISTS "timetables" CASCADE;
DROP TABLE IF EXISTS "timetable_periods" CASCADE; DROP TABLE IF EXISTS "timetable_periods" CASCADE;
DROP TABLE IF EXISTS "subjects" CASCADE; DROP TABLE IF EXISTS "subjects" CASCADE;
DROP TABLE IF EXISTS "staff_attendance_records" 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 "safety_quiz_results" CASCADE;
DROP TABLE IF EXISTS "roles" CASCADE; DROP TABLE IF EXISTS "roles" CASCADE;
DROP TABLE IF EXISTS "personality_quiz_results" CASCADE; DROP TABLE IF EXISTS "personality_quiz_results" CASCADE;
@ -106,8 +102,6 @@ DROP TYPE IF EXISTS "public"."enum_message_recipients_delivery_status";
DROP TYPE IF EXISTS "public"."enum_messages_channel"; DROP TYPE IF EXISTS "public"."enum_messages_channel";
DROP TYPE IF EXISTS "public"."enum_messages_audience"; DROP TYPE IF EXISTS "public"."enum_messages_audience";
DROP TYPE IF EXISTS "public"."enum_messages_status"; DROP TYPE IF EXISTS "public"."enum_messages_status";
DROP TYPE IF EXISTS "public"."enum_staff_staff_type";
DROP TYPE IF EXISTS "public"."enum_staff_status";
DROP TYPE IF EXISTS "public"."enum_staff_attendance_records_status"; DROP TYPE IF EXISTS "public"."enum_staff_attendance_records_status";
DROP TYPE IF EXISTS "public"."enum_timetable_periods_day_of_week"; 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_timetables_status";

View File

@ -46,13 +46,30 @@ async function columnIsNullable(
export default { export default {
up: async (queryInterface: QueryInterface) => { up: async (queryInterface: QueryInterface) => {
// Create enum type if not exists // Create enum type if not exists, or add missing values to existing enum
await queryInterface.sequelize.query(` const [enumExists] = await queryInterface.sequelize.query(`
DO 'BEGIN SELECT 1 FROM pg_type WHERE typname = 'enum_roles_scope'
CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `''${v}''`).join(', ')});
EXCEPTION WHEN duplicate_object THEN null; END';
`); `);
if ((enumExists as unknown[]).length === 0) {
// Enum doesn't exist, create it with all values
await queryInterface.sequelize.query(`
CREATE TYPE "public"."enum_roles_scope" AS ENUM(${ROLE_SCOPE_VALUES.map((v) => `'${v}'`).join(', ')});
`);
} else {
// Enum exists, add any missing values
for (const scope of ROLE_SCOPE_VALUES) {
const [valueExists] = await queryInterface.sequelize.query(`
SELECT 1 FROM pg_enum WHERE enumtypid = 'enum_roles_scope'::regtype AND enumlabel = '${scope}'
`);
if ((valueExists as unknown[]).length === 0) {
await queryInterface.sequelize.query(`
ALTER TYPE "public"."enum_roles_scope" ADD VALUE IF NOT EXISTS '${scope}'
`);
}
}
}
const scopeExists = await columnExists(queryInterface, 'roles', 'scope'); const scopeExists = await columnExists(queryInterface, 'roles', 'scope');
if (!scopeExists) { if (!scopeExists) {

View File

@ -2,8 +2,8 @@ import { DataTypes, type QueryInterface } from 'sequelize';
/** /**
* School tier (American Organization School Campus hierarchy). Adds the * School tier (American Organization School Campus hierarchy). Adds the
* `schools` table and a nullable `schoolId` foreign key on `campuses`, `users`, * `schools` table and a nullable `schoolId` foreign key on `campuses` and
* and `staff`. Like every other relation in this codebase the link is an * `users`. Like every other relation in this codebase the link is an
* app-level UUID column with no DB-level constraint (`constraints: false` in the * app-level UUID column with no DB-level constraint (`constraints: false` in the
* models). `schoolId` is left nullable here; the reseed assigns every campus to * models). `schoolId` is left nullable here; the reseed assigns every campus to
* a school (campus belongs to exactly one school). Idempotent: the table and * a school (campus belongs to exactly one school). Idempotent: the table and
@ -80,13 +80,9 @@ export default {
await addSchoolIdColumn(queryInterface, 'campuses'); await addSchoolIdColumn(queryInterface, 'campuses');
await addSchoolIdColumn(queryInterface, 'users'); await addSchoolIdColumn(queryInterface, 'users');
await addSchoolIdColumn(queryInterface, 'staff');
}, },
down: async (queryInterface: QueryInterface) => { down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'staff', 'schoolId')) {
await queryInterface.removeColumn('staff', 'schoolId');
}
if (await columnExists(queryInterface, 'users', 'schoolId')) { if (await columnExists(queryInterface, 'users', 'schoolId')) {
await queryInterface.removeColumn('users', 'schoolId'); await queryInterface.removeColumn('users', 'schoolId');
} }

View File

@ -0,0 +1,43 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Class tier (Organization School Campus Class). Adds a nullable `classId`
* foreign key on `users` for class-bound roles (Teacher / Support Staff).
* Like every other relation here the link is an app-level UUID with no
* DB-level constraint. Idempotent: each column is only added if missing.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
async function addClassIdColumn(
queryInterface: QueryInterface,
table: string,
): Promise<void> {
if (!(await columnExists(queryInterface, table, 'classId'))) {
await queryInterface.addColumn(table, 'classId', {
type: DataTypes.UUID,
allowNull: true,
});
}
}
export default {
up: async (queryInterface: QueryInterface) => {
await addClassIdColumn(queryInterface, 'users');
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'users', 'classId')) {
await queryInterface.removeColumn('users', 'classId');
}
},
};

View File

@ -0,0 +1,49 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Tenant branding: a `logo` URL on every tenant level (organizations, schools,
* campuses, classes) for the dynamic sidebar/top-bar badge and the tenant
* creation forms. Nullable text (a stored file `privateUrl`/download URL or an
* external URL). Idempotent: only added if missing.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
async function addLogoColumn(
queryInterface: QueryInterface,
table: string,
): Promise<void> {
if (!(await columnExists(queryInterface, table, 'logo'))) {
await queryInterface.addColumn(table, 'logo', {
type: DataTypes.TEXT,
allowNull: true,
});
}
}
const TABLES = ['organizations', 'schools', 'campuses', 'classes'] as const;
export default {
up: async (queryInterface: QueryInterface) => {
for (const table of TABLES) {
await addLogoColumn(queryInterface, table);
}
},
down: async (queryInterface: QueryInterface) => {
for (const table of TABLES) {
if (await columnExists(queryInterface, table, 'logo')) {
await queryInterface.removeColumn(table, 'logo');
}
}
},
};

View File

@ -0,0 +1,46 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Guardian student link (many-to-many). Students and guardians are users
* (roles); this junction relates two `users` rows so a student can have several
* guardians and a guardian several students. App-level UUIDs, no DB-level FK
* constraints (consistent with the rest of the schema). Idempotent.
*/
async function tableExists(
queryInterface: QueryInterface,
table: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.tables WHERE table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await tableExists(queryInterface, 'guardian_students'))) {
await queryInterface.createTable('guardian_students', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
guardianId: { type: DataTypes.UUID, allowNull: false },
studentId: { type: DataTypes.UUID, allowNull: false },
relationship: { type: DataTypes.TEXT, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'guardian_students')) {
await queryInterface.dropTable('guardian_students');
}
},
};

View File

@ -0,0 +1,45 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Per-tenant FRAME content (WS-B). FRAME entries become dedicated per tenant
* level each of org / school / campus / class owns its own entries. Adds
* nullable `schoolId` and `classId`; the owning tenant is the most specific
* non-null of (classId, campusId, schoolId, organizationId). Idempotent.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'frame_entries', 'schoolId'))) {
await queryInterface.addColumn('frame_entries', 'schoolId', {
type: DataTypes.UUID,
allowNull: true,
});
}
if (!(await columnExists(queryInterface, 'frame_entries', 'classId'))) {
await queryInterface.addColumn('frame_entries', 'classId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'frame_entries', 'classId')) {
await queryInterface.removeColumn('frame_entries', 'classId');
}
if (await columnExists(queryInterface, 'frame_entries', 'schoolId')) {
await queryInterface.removeColumn('frame_entries', 'schoolId');
}
},
};

View File

@ -0,0 +1,45 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Per-tenant policy documents (WS-B). Documents become dedicated per tenant
* level each of org / school / campus / class owns its own. Adds nullable
* `schoolId` and `classId`; the owning tenant is the most specific non-null of
* (classId, campusId, schoolId, organizationId). Idempotent.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'policy_documents', 'schoolId'))) {
await queryInterface.addColumn('policy_documents', 'schoolId', {
type: DataTypes.UUID,
allowNull: true,
});
}
if (!(await columnExists(queryInterface, 'policy_documents', 'classId'))) {
await queryInterface.addColumn('policy_documents', 'classId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'policy_documents', 'classId')) {
await queryInterface.removeColumn('policy_documents', 'classId');
}
if (await columnExists(queryInterface, 'policy_documents', 'schoolId')) {
await queryInterface.removeColumn('policy_documents', 'schoolId');
}
},
};

View File

@ -0,0 +1,56 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Per-tenant safety-quiz content (WS-B). The safety-quiz `content_type` becomes
* dedicated per tenant (org/school/campus/class); other content types stay
* org-level. Adds nullable tenant columns and drops the `content_type` UNIQUE
* constraint (a tenant-scoped type has one row per owning tenant). Idempotent.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
const COLUMNS = ['organizationId', 'schoolId', 'campusId', 'classId'] as const;
export default {
up: async (queryInterface: QueryInterface) => {
for (const column of COLUMNS) {
if (!(await columnExists(queryInterface, 'content_catalog', column))) {
await queryInterface.addColumn('content_catalog', column, {
type: DataTypes.UUID,
allowNull: true,
});
}
}
// Drop the UNIQUE constraint on content_type if present (name may vary).
await queryInterface.sequelize.query(`
DO $$
DECLARE c text;
BEGIN
FOR c IN
SELECT conname FROM pg_constraint
WHERE conrelid = 'content_catalog'::regclass AND contype = 'u'
AND pg_get_constraintdef(oid) ILIKE '%(content_type)%'
LOOP
EXECUTE 'ALTER TABLE content_catalog DROP CONSTRAINT ' || quote_ident(c);
END LOOP;
END $$;
`);
},
down: async (queryInterface: QueryInterface) => {
for (const column of COLUMNS) {
if (await columnExists(queryInterface, 'content_catalog', column)) {
await queryInterface.removeColumn('content_catalog', column);
}
}
},
};

View File

@ -0,0 +1,45 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Exact-tenant walk-through check-ins (WS, scope map). A boss evaluates only
* their own tenant's staff org/school/campus each own theirs. Adds nullable
* `schoolId` and `classId` (the owning tenant is the most specific non-null of
* (classId, campusId, schoolId, organizationId)). Idempotent.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'walkthrough_checkins', 'schoolId'))) {
await queryInterface.addColumn('walkthrough_checkins', 'schoolId', {
type: DataTypes.UUID,
allowNull: true,
});
}
if (!(await columnExists(queryInterface, 'walkthrough_checkins', 'classId'))) {
await queryInterface.addColumn('walkthrough_checkins', 'classId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'walkthrough_checkins', 'classId')) {
await queryInterface.removeColumn('walkthrough_checkins', 'classId');
}
if (await columnExists(queryInterface, 'walkthrough_checkins', 'schoolId')) {
await queryInterface.removeColumn('walkthrough_checkins', 'schoolId');
}
},
};

View File

@ -0,0 +1,45 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Exact-tenant internal alerts (scope map). Internal alerts
* (`communication_events`) are dedicated per tenant a campus doesn't see
* org-level alerts and vice-versa. Adds nullable `schoolId` and `classId`.
* Idempotent.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'communication_events', 'schoolId'))) {
await queryInterface.addColumn('communication_events', 'schoolId', {
type: DataTypes.UUID,
allowNull: true,
});
}
if (!(await columnExists(queryInterface, 'communication_events', 'classId'))) {
await queryInterface.addColumn('communication_events', 'classId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'communication_events', 'classId')) {
await queryInterface.removeColumn('communication_events', 'classId');
}
if (await columnExists(queryInterface, 'communication_events', 'schoolId')) {
await queryInterface.removeColumn('communication_events', 'schoolId');
}
},
};

View File

@ -0,0 +1,53 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Class-level daily attendance (WS-C). The teacher fills a per-class aggregate
* that rolls up to campus school org. The owning class's campus/school/org
* are denormalized onto the row so a tier rollup is a single-column SUM filter.
* Idempotent.
*/
async function tableExists(
queryInterface: QueryInterface,
table: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.tables WHERE table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await tableExists(queryInterface, 'class_attendance'))) {
await queryInterface.createTable('class_attendance', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
classId: { type: DataTypes.UUID, allowNull: false },
attendance_date: { type: DataTypes.DATEONLY, allowNull: false },
total_enrolled: { type: DataTypes.INTEGER, allowNull: false },
total_present: { type: DataTypes.INTEGER, allowNull: false },
total_absent: { type: DataTypes.INTEGER, allowNull: false },
total_tardy: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
attendance_percentage: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
recorded_by_label: { type: DataTypes.TEXT, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'class_attendance')) {
await queryInterface.dropTable('class_attendance');
}
},
};

View File

@ -0,0 +1,51 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Direct 1:1 messages between a staff member (teacher / office_manager) and a
* guardian. A conversation is the unordered pair {senderId, recipientId}; both
* parties are discovered through a shared student (`studentId` is the connecting
* context). Access is membership-based a user only ever reads rows where they
* are the sender or the recipient, so conversations are naturally isolated.
* App-level UUIDs, no DB-level FK constraints (consistent with the schema).
* Idempotent.
*/
async function tableExists(
queryInterface: QueryInterface,
table: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.tables WHERE table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await tableExists(queryInterface, 'direct_messages'))) {
await queryInterface.createTable('direct_messages', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
senderId: { type: DataTypes.UUID, allowNull: false },
recipientId: { type: DataTypes.UUID, allowNull: false },
studentId: { type: DataTypes.UUID, allowNull: true },
body: { type: DataTypes.TEXT, allowNull: false },
readAt: { type: DataTypes.DATE, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
deletedAt: { type: DataTypes.DATE, allowNull: true },
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'direct_messages')) {
await queryInterface.dropTable('direct_messages');
}
},
};

View File

@ -0,0 +1,26 @@
import { Op, type QueryInterface } from 'sequelize';
const GLOBAL_STATIC_CONTENT_TYPES = Object.freeze([
'classroom-timer-backgrounds',
'classroom-timer-sounds',
'classroom-timer-presets',
'classroom-timer-tips',
'personality-quiz-questions',
'personality-types',
'personality-quiz-features',
'personality-workplace-content',
]);
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.bulkDelete('content_catalog', {
content_type: {
[Op.in]: GLOBAL_STATIC_CONTENT_TYPES,
},
});
},
down: async () => {
// Intentionally no-op: these records moved to frontend static constants.
},
};

View File

@ -0,0 +1,155 @@
import { v4 as uuid } from 'uuid';
import type { QueryInterface } from 'sequelize';
import { ROLE_NAMES, type RoleName } from '@/shared/constants/roles';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
const PERMISSIONS = Object.freeze([
FEATURE_PERMISSIONS.MANAGE_FRAME,
FEATURE_PERMISSIONS.MANAGE_WALKTHROUGH,
FEATURE_PERMISSIONS.MANAGE_INTERNAL_COMM,
FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG,
FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS,
FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS,
FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS,
FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS,
]);
const FULL_ACCESS_ROLES: readonly RoleName[] = Object.freeze([
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
]);
const REPORT_PERMISSIONS = Object.freeze([
FEATURE_PERMISSIONS.READ_STAFF_ATTENDANCE_REPORTS,
FEATURE_PERMISSIONS.READ_SAFETY_QUIZ_REPORTS,
FEATURE_PERMISSIONS.READ_PERSONALITY_REPORTS,
FEATURE_PERMISSIONS.READ_POLICY_ACKNOWLEDGMENT_REPORTS,
]);
const ROLE_GRANTS: Readonly<Record<RoleName, readonly string[]>> = Object.freeze({
[ROLE_NAMES.SUPER_ADMIN]: [],
[ROLE_NAMES.SYSTEM_ADMIN]: [],
[ROLE_NAMES.OWNER]: PERMISSIONS,
[ROLE_NAMES.SUPERINTENDENT]: PERMISSIONS,
[ROLE_NAMES.PRINCIPAL]: PERMISSIONS,
[ROLE_NAMES.REGISTRAR]: REPORT_PERMISSIONS,
[ROLE_NAMES.DIRECTOR]: PERMISSIONS,
[ROLE_NAMES.OFFICE_MANAGER]: [],
[ROLE_NAMES.TEACHER]: [],
[ROLE_NAMES.SUPPORT_STAFF]: [],
[ROLE_NAMES.STUDENT]: [],
[ROLE_NAMES.GUARDIAN]: [],
[ROLE_NAMES.GUEST]: [],
});
function isNamedIdRow(value: unknown): value is { id: string; name: string } {
return (
value !== null
&& typeof value === 'object'
&& 'id' in value
&& typeof value.id === 'string'
&& 'name' in value
&& typeof value.name === 'string'
);
}
function resultRows(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
async function rowsByName(
queryInterface: QueryInterface,
table: string,
names: readonly string[],
): Promise<Map<string, string>> {
const [rows] = await queryInterface.sequelize.query(
`SELECT "id", "name" FROM "${table}" WHERE "name" IN (:names)`,
{ replacements: { names } },
);
return new Map(
resultRows(rows)
.filter(isNamedIdRow)
.map((row) => [row.name, row.id]),
);
}
export default {
up: async (queryInterface: QueryInterface) => {
const now = new Date();
const permissionIds = await rowsByName(queryInterface, 'permissions', PERMISSIONS);
const missing = PERMISSIONS.filter((name) => !permissionIds.has(name));
if (missing.length > 0) {
const rows = missing.map((name) => {
const id = uuid();
permissionIds.set(name, id);
return { id, name, createdAt: now, updatedAt: now };
});
await queryInterface.bulkInsert('permissions', rows);
}
const roleIds = await rowsByName(queryInterface, 'roles', Object.values(ROLE_NAMES));
const links: Array<{
createdAt: Date;
updatedAt: Date;
roles_permissionsId: string;
permissionId: string;
}> = [];
for (const role of FULL_ACCESS_ROLES) {
const roleId = roleIds.get(role);
if (!roleId) continue;
for (const permissionName of ROLE_GRANTS[role]) {
const permissionId = permissionIds.get(permissionName);
if (!permissionId) continue;
const [existing] = await queryInterface.sequelize.query(
`SELECT 1 FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
LIMIT 1`,
{ replacements: { roleId, permissionId } },
);
if (resultRows(existing).length === 0) {
links.push({
createdAt: now,
updatedAt: now,
roles_permissionsId: roleId,
permissionId,
});
}
}
}
const registrarId = roleIds.get(ROLE_NAMES.REGISTRAR);
if (registrarId) {
for (const permissionName of REPORT_PERMISSIONS) {
const permissionId = permissionIds.get(permissionName);
if (!permissionId) continue;
const [existing] = await queryInterface.sequelize.query(
`SELECT 1 FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId AND "permissionId" = :permissionId
LIMIT 1`,
{ replacements: { roleId: registrarId, permissionId } },
);
if (resultRows(existing).length === 0) {
links.push({
createdAt: now,
updatedAt: now,
roles_permissionsId: registrarId,
permissionId,
});
}
}
}
if (links.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', links);
}
},
down: async () => {
// Intentionally no-op: permission rows are safe to keep and may be assigned
// as per-user custom permissions.
},
};

View File

@ -0,0 +1,25 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Platform-scope internal alerts are owned by the global scope, represented by
* a null tenant chain. Tenant alerts still carry organization/school/campus ids.
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.changeColumn('communication_events', 'organizationId', {
type: DataTypes.UUID,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(`
DELETE FROM "communication_events"
WHERE "organizationId" IS NULL
`);
await queryInterface.changeColumn('communication_events', 'organizationId', {
type: DataTypes.UUID,
allowNull: false,
});
},
};

View File

@ -0,0 +1,36 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Internal alerts target exact audience tiers. The tenant ids identify the
* selected organization/school/campus; targetLevel distinguishes platform
* system-only alerts from platform all-user broadcasts.
*/
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'communication_events', 'targetLevel'))) {
await queryInterface.addColumn('communication_events', 'targetLevel', {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'campus',
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'communication_events', 'targetLevel')) {
await queryInterface.removeColumn('communication_events', 'targetLevel');
}
},
};

View File

@ -0,0 +1,30 @@
import { DataTypes, type QueryInterface } from 'sequelize';
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (!(await columnExists(queryInterface, 'communication_events', 'canceledEventId'))) {
await queryInterface.addColumn('communication_events', 'canceledEventId', {
type: DataTypes.UUID,
allowNull: true,
});
}
},
down: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'communication_events', 'canceledEventId')) {
await queryInterface.removeColumn('communication_events', 'canceledEventId');
}
},
};

View File

@ -0,0 +1,31 @@
import { type QueryInterface } from 'sequelize';
async function tableExists(
queryInterface: QueryInterface,
table: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '${table}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (await tableExists(queryInterface, 'staff')) {
await queryInterface.dropTable('staff');
}
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "public"."enum_staff_staff_type"',
);
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "public"."enum_staff_status"',
);
},
down: async (queryInterface: QueryInterface) => {
void queryInterface;
},
};

View File

@ -0,0 +1,94 @@
import type { QueryInterface } from 'sequelize';
import { ROLE_NAMES } from '@/shared/constants/roles';
type NamedRow = {
readonly id: string;
readonly name: string;
};
function isNamedRow(value: unknown): value is NamedRow {
return (
value !== null
&& typeof value === 'object'
&& 'id' in value
&& typeof value.id === 'string'
&& 'name' in value
&& typeof value.name === 'string'
);
}
function rowsOf(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
export default {
async up(queryInterface: QueryInterface) {
const now = new Date();
const [roleRows] = await queryInterface.sequelize.query(
`SELECT "id", "name" FROM "roles" WHERE "name" = :name LIMIT 1`,
{ replacements: { name: ROLE_NAMES.SYSTEM_ADMIN } },
);
const systemAdminRole = rowsOf(roleRows).find(isNamedRow);
if (!systemAdminRole) {
return;
}
const [permissionRows] = await queryInterface.sequelize.query(
`SELECT "id", "name" FROM "permissions"`,
);
const permissions = rowsOf(permissionRows).filter(isNamedRow);
if (permissions.length === 0) {
return;
}
const [existingRows] = await queryInterface.sequelize.query(
`SELECT "permissionId"
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId`,
{ replacements: { roleId: systemAdminRole.id } },
);
const existingPermissionIds = new Set(
rowsOf(existingRows)
.map((row) =>
row !== null
&& typeof row === 'object'
&& 'permissionId' in row
&& typeof row.permissionId === 'string'
? row.permissionId
: null,
)
.filter((id): id is string => id !== null),
);
const links = permissions
.filter((permission) => !existingPermissionIds.has(permission.id))
.map((permission) => ({
createdAt: now,
updatedAt: now,
roles_permissionsId: systemAdminRole.id,
permissionId: permission.id,
}));
if (links.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', links);
}
},
async down(queryInterface: QueryInterface) {
const [roleRows] = await queryInterface.sequelize.query(
`SELECT "id", "name" FROM "roles" WHERE "name" = :name LIMIT 1`,
{ replacements: { name: ROLE_NAMES.SYSTEM_ADMIN } },
);
const systemAdminRole = rowsOf(roleRows).find(isNamedRow);
if (!systemAdminRole) {
return;
}
await queryInterface.bulkDelete('rolesPermissionsPermissions', {
roles_permissionsId: systemAdminRole.id,
});
},
};

View File

@ -0,0 +1,147 @@
import { Op, QueryTypes, type QueryInterface } from 'sequelize';
import { v4 as uuid } from 'uuid';
import type { ContentCatalog } from '@/db/models/content_catalog';
import type { CreationAttributes } from 'sequelize';
import { PER_TENANT_CONTENT_TYPES } from '@/shared/constants/content-catalog';
import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
const BACKFILL_IMPORT_HASH_PREFIX = 'dashboard-content-scope-backfill';
interface TenantRow {
readonly id: string;
readonly organizationId?: string | null;
}
interface ContentStamp {
readonly level: 'organization' | 'school' | 'campus';
readonly organizationId: string;
readonly schoolId: string | null;
readonly campusId: string | null;
}
const perTenantDefaultRows = CONTENT_CATALOG_DEFAULT_ROWS.filter((row) =>
PER_TENANT_CONTENT_TYPES.has(row.content_type),
);
async function exactContentRowExists(
queryInterface: QueryInterface,
contentType: string,
stamp: ContentStamp,
): Promise<boolean> {
const rows = await queryInterface.sequelize.query<{ id: string }>(
`
SELECT id
FROM content_catalog
WHERE content_type = :contentType
AND active = true
AND "deletedAt" IS NULL
AND "organizationId" = :organizationId
AND "schoolId" ${stamp.schoolId ? '= :schoolId' : 'IS NULL'}
AND "campusId" ${stamp.campusId ? '= :campusId' : 'IS NULL'}
AND "classId" IS NULL
LIMIT 1
`,
{
replacements: {
contentType,
organizationId: stamp.organizationId,
schoolId: stamp.schoolId,
campusId: stamp.campusId,
},
type: QueryTypes.SELECT,
},
);
return rows.length > 0;
}
function importHash(contentType: string, stamp: ContentStamp): string {
return [
BACKFILL_IMPORT_HASH_PREFIX,
contentType,
stamp.level,
stamp.campusId ?? stamp.schoolId ?? stamp.organizationId,
].join('-');
}
export default {
up: async (queryInterface: QueryInterface) => {
const [organizations, schools, campuses] = await Promise.all([
queryInterface.sequelize.query<TenantRow>(
'SELECT id FROM organizations WHERE "deletedAt" IS NULL',
{ type: QueryTypes.SELECT },
),
queryInterface.sequelize.query<TenantRow>(
'SELECT id, "organizationId" FROM schools WHERE "deletedAt" IS NULL',
{ type: QueryTypes.SELECT },
),
queryInterface.sequelize.query<TenantRow>(
'SELECT id, "organizationId" FROM campuses WHERE "deletedAt" IS NULL',
{ type: QueryTypes.SELECT },
),
]);
const stamps: ContentStamp[] = [
...organizations.map((organization) => ({
level: 'organization' as const,
organizationId: organization.id,
schoolId: null,
campusId: null,
})),
...schools
.filter((school): school is TenantRow & { readonly organizationId: string } =>
typeof school.organizationId === 'string',
)
.map((school) => ({
level: 'school' as const,
organizationId: school.organizationId,
schoolId: school.id,
campusId: null,
})),
...campuses
.filter((campus): campus is TenantRow & { readonly organizationId: string } =>
typeof campus.organizationId === 'string',
)
.map((campus) => ({
level: 'campus' as const,
organizationId: campus.organizationId,
schoolId: null,
campusId: campus.id,
})),
];
const now = new Date();
const rows: CreationAttributes<ContentCatalog>[] = [];
for (const contentRow of perTenantDefaultRows) {
for (const stamp of stamps) {
if (await exactContentRowExists(queryInterface, contentRow.content_type, stamp)) {
continue;
}
rows.push({
id: uuid(),
content_type: contentRow.content_type,
payload: JSON.stringify(contentRow.payload),
active: true,
importHash: importHash(contentRow.content_type, stamp),
organizationId: stamp.organizationId,
schoolId: stamp.schoolId,
campusId: stamp.campusId,
classId: null,
createdAt: now,
updatedAt: now,
});
}
}
if (rows.length > 0) {
await queryInterface.bulkInsert('content_catalog', rows);
}
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.bulkDelete('content_catalog', {
importHash: { [Op.like]: `${BACKFILL_IMPORT_HASH_PREFIX}%` },
});
},
};

View File

@ -0,0 +1,28 @@
import { type QueryInterface } from 'sequelize';
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'users', 'staff_type')) {
await queryInterface.removeColumn('users', 'staff_type');
}
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "public"."enum_staff_staff_type"',
);
},
down: async () => {
// Pre-launch cleanup: staff classification is role + scope only.
},
};

View File

@ -0,0 +1,29 @@
import { type QueryInterface } from 'sequelize';
const DROPPED_USER_COLUMNS = ['job_title', 'hire_date'] as const;
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
for (const column of DROPPED_USER_COLUMNS) {
if (await columnExists(queryInterface, 'users', column)) {
await queryInterface.removeColumn('users', column);
}
}
},
down: async () => {
// Pre-launch cleanup: role + scope are the canonical user classification.
},
};

View File

@ -0,0 +1,29 @@
import { type QueryInterface } from 'sequelize';
const DROPPED_USER_COLUMNS = ['employee_number', 'status'] as const;
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
for (const column of DROPPED_USER_COLUMNS) {
if (await columnExists(queryInterface, 'users', column)) {
await queryInterface.removeColumn('users', column);
}
}
},
down: async () => {
// Pre-launch cleanup: user identity is role + scope, not HR metadata.
},
};

View File

@ -0,0 +1,25 @@
import { type QueryInterface } from 'sequelize';
async function columnExists(
queryInterface: QueryInterface,
table: string,
column: string,
): Promise<boolean> {
const [results] = await queryInterface.sequelize.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = '${column}'
`);
return (results as unknown[]).length > 0;
}
export default {
up: async (queryInterface: QueryInterface) => {
if (await columnExists(queryInterface, 'users', 'importHash')) {
await queryInterface.removeColumn('users', 'importHash');
}
},
down: async () => {
// Pre-launch cleanup: users are created through auth/provisioning, not import hashes.
},
};

View File

@ -0,0 +1,15 @@
import type { QueryInterface } from 'sequelize';
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.sequelize.query(`
ALTER TYPE "public"."enum_user_progress_progress_type"
ADD VALUE IF NOT EXISTS 'classroom_strategy_favorite';
`);
},
down: async () => {
// PostgreSQL enum values cannot be removed safely while rows may reference
// them. Keep the value in place; rollback only removes application usage.
},
};

View File

@ -18,7 +18,6 @@ import type { Campuses } from './campuses';
import type { ClassSubjects } from './class_subjects'; import type { ClassSubjects } from './class_subjects';
import type { Classes } from './classes'; import type { Classes } from './classes';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Staff } from './staff';
import type { Users } from './users'; import type { Users } from './users';
export class AttendanceSessions extends Model< export class AttendanceSessions extends Model<
@ -52,8 +51,8 @@ export class AttendanceSessions extends Model<
declare setClass: BelongsToSetAssociationMixin<Classes, string>; declare setClass: BelongsToSetAssociationMixin<Classes, string>;
declare getClass_subject: BelongsToGetAssociationMixin<ClassSubjects>; declare getClass_subject: BelongsToGetAssociationMixin<ClassSubjects>;
declare setClass_subject: BelongsToSetAssociationMixin<ClassSubjects, string>; declare setClass_subject: BelongsToSetAssociationMixin<ClassSubjects, string>;
declare getTaken_by: BelongsToGetAssociationMixin<Staff>; declare getTaken_by: BelongsToGetAssociationMixin<Users>;
declare setTaken_by: BelongsToSetAssociationMixin<Staff, string>; declare setTaken_by: BelongsToSetAssociationMixin<Users, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>; declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>; declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>; declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -136,7 +135,7 @@ export class AttendanceSessions extends Model<
constraints: false, constraints: false,
}); });
db.attendance_sessions.belongsTo(db.staff, { db.attendance_sessions.belongsTo(db.users, {
as: 'taken_by', as: 'taken_by',
foreignKey: { foreignKey: {
name: 'taken_byId', name: 'taken_byId',

View File

@ -18,7 +18,6 @@ import type { Classes } from './classes';
import type { Messages } from './messages'; import type { Messages } from './messages';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Schools } from './schools'; import type { Schools } from './schools';
import type { Staff } from './staff';
import type { Timetables } from './timetables'; import type { Timetables } from './timetables';
import type { Users } from './users'; import type { Users } from './users';
import { isValidIanaTimezone } from '@/shared/constants/timezone'; import { isValidIanaTimezone } from '@/shared/constants/timezone';
@ -41,6 +40,7 @@ export class Campuses extends Model<
declare textColor: string | null; declare textColor: string | null;
declare bgLight: string | null; declare bgLight: string | null;
declare description: string | null; declare description: string | null;
declare logo: CreationOptional<string | null>;
declare isOnline: CreationOptional<boolean>; declare isOnline: CreationOptional<boolean>;
declare active: CreationOptional<boolean>; declare active: CreationOptional<boolean>;
declare importHash: CreationOptional<string | null>; declare importHash: CreationOptional<string | null>;
@ -54,8 +54,6 @@ export class Campuses extends Model<
declare deletedAt: CreationOptional<Date | null>; declare deletedAt: CreationOptional<Date | null>;
declare getStaff_campus: HasManyGetAssociationsMixin<Staff>;
declare setStaff_campus: HasManySetAssociationsMixin<Staff, string>;
declare getClasses_campus: HasManyGetAssociationsMixin<Classes>; declare getClasses_campus: HasManyGetAssociationsMixin<Classes>;
declare setClasses_campus: HasManySetAssociationsMixin<Classes, string>; declare setClasses_campus: HasManySetAssociationsMixin<Classes, string>;
declare getTimetables_campus: HasManyGetAssociationsMixin<Timetables>; declare getTimetables_campus: HasManyGetAssociationsMixin<Timetables>;
@ -74,12 +72,6 @@ export class Campuses extends Model<
declare setUpdatedBy: BelongsToSetAssociationMixin<Users, string>; declare setUpdatedBy: BelongsToSetAssociationMixin<Users, string>;
static associate(db: Db): void { static associate(db: Db): void {
db.campuses.hasMany(db.staff, {
as: 'staff_campus',
foreignKey: { name: 'campusId' },
constraints: false,
});
db.campuses.hasMany(db.classes, { db.campuses.hasMany(db.classes, {
as: 'classes_campus', as: 'classes_campus',
foreignKey: { name: 'campusId' }, foreignKey: { name: 'campusId' },
@ -152,6 +144,7 @@ export default function (sequelize: Sequelize): typeof Campuses {
textColor: { type: DataTypes.TEXT }, textColor: { type: DataTypes.TEXT },
bgLight: { type: DataTypes.TEXT }, bgLight: { type: DataTypes.TEXT },
description: { type: DataTypes.TEXT }, description: { type: DataTypes.TEXT },
logo: { type: DataTypes.TEXT },
isOnline: { isOnline: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,

View File

@ -0,0 +1,88 @@
import {
DataTypes,
Model,
type CreationOptional,
type InferAttributes,
type InferCreationAttributes,
type Sequelize,
} from 'sequelize';
import type { Db } from '@/db/types';
import type { BelongsToGetAssociationMixin } from 'sequelize';
import type { Classes } from './classes';
/**
* Class-level daily attendance aggregate (WS-C), filled by the class teacher
* (same shape as the campus aggregate). It is the **source** that rolls up to
* campus school org: the owning class's `campusId`/`schoolId`/
* `organizationId` are denormalized onto the row so a tier rollup is a simple
* `SUM` with a single-column filter (no joins). One row per class per date.
*/
export class ClassAttendance extends Model<
InferAttributes<ClassAttendance>,
InferCreationAttributes<ClassAttendance>
> {
declare id: CreationOptional<string>;
declare classId: string;
declare attendance_date: string;
declare total_enrolled: number;
declare total_present: number;
declare total_absent: number;
declare total_tardy: CreationOptional<number>;
declare attendance_percentage: CreationOptional<string | null>;
declare recorded_by_label: CreationOptional<string | null>;
declare organizationId: CreationOptional<string | null>;
declare schoolId: CreationOptional<string | null>;
declare campusId: CreationOptional<string | null>;
declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>;
declare getClass: BelongsToGetAssociationMixin<Classes>;
static associate(db: Db): void {
db.class_attendance.belongsTo(db.classes, {
as: 'class',
foreignKey: { name: 'classId' },
constraints: false,
});
}
}
export default function (sequelize: Sequelize): typeof ClassAttendance {
ClassAttendance.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
classId: { type: DataTypes.UUID, allowNull: false },
attendance_date: { type: DataTypes.DATEONLY, allowNull: false },
total_enrolled: { type: DataTypes.INTEGER, allowNull: false },
total_present: { type: DataTypes.INTEGER, allowNull: false },
total_absent: { type: DataTypes.INTEGER, allowNull: false },
total_tardy: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
attendance_percentage: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
recorded_by_label: { type: DataTypes.TEXT, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
},
{
sequelize,
modelName: 'class_attendance',
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
return ClassAttendance;
}

View File

@ -17,7 +17,6 @@ import type { Assessments } from './assessments';
import type { AttendanceSessions } from './attendance_sessions'; import type { AttendanceSessions } from './attendance_sessions';
import type { Classes } from './classes'; import type { Classes } from './classes';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Staff } from './staff';
import type { Subjects } from './subjects'; import type { Subjects } from './subjects';
import type { TimetablePeriods } from './timetable_periods'; import type { TimetablePeriods } from './timetable_periods';
import type { Users } from './users'; import type { Users } from './users';
@ -52,8 +51,8 @@ export class ClassSubjects extends Model<
declare setClass: BelongsToSetAssociationMixin<Classes, string>; declare setClass: BelongsToSetAssociationMixin<Classes, string>;
declare getSubject: BelongsToGetAssociationMixin<Subjects>; declare getSubject: BelongsToGetAssociationMixin<Subjects>;
declare setSubject: BelongsToSetAssociationMixin<Subjects, string>; declare setSubject: BelongsToSetAssociationMixin<Subjects, string>;
declare getTeacher: BelongsToGetAssociationMixin<Staff>; declare getTeacher: BelongsToGetAssociationMixin<Users>;
declare setTeacher: BelongsToSetAssociationMixin<Staff, string>; declare setTeacher: BelongsToSetAssociationMixin<Users, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>; declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>; declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>; declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -144,7 +143,7 @@ export class ClassSubjects extends Model<
constraints: false, constraints: false,
}); });
db.class_subjects.belongsTo(db.staff, { db.class_subjects.belongsTo(db.users, {
as: 'teacher', as: 'teacher',
foreignKey: { foreignKey: {
name: 'teacherId', name: 'teacherId',

View File

@ -20,7 +20,6 @@ import type { ClassEnrollments } from './class_enrollments';
import type { ClassSubjects } from './class_subjects'; import type { ClassSubjects } from './class_subjects';
import type { Grades } from './grades'; import type { Grades } from './grades';
import type { Organizations } from './organizations'; import type { Organizations } from './organizations';
import type { Staff } from './staff';
import type { Users } from './users'; import type { Users } from './users';
export class Classes extends Model< export class Classes extends Model<
@ -30,6 +29,7 @@ export class Classes extends Model<
declare id: CreationOptional<string>; declare id: CreationOptional<string>;
declare name: string | null; declare name: string | null;
declare section: string | null; declare section: string | null;
declare logo: CreationOptional<string | null>;
declare capacity: number | null; declare capacity: number | null;
declare status: string | null; declare status: string | null;
declare importHash: CreationOptional<string | null>; declare importHash: CreationOptional<string | null>;
@ -59,8 +59,8 @@ export class Classes extends Model<
declare setAcademic_year: BelongsToSetAssociationMixin<AcademicYears, string>; declare setAcademic_year: BelongsToSetAssociationMixin<AcademicYears, string>;
declare getGrade: BelongsToGetAssociationMixin<Grades>; declare getGrade: BelongsToGetAssociationMixin<Grades>;
declare setGrade: BelongsToSetAssociationMixin<Grades, string>; declare setGrade: BelongsToSetAssociationMixin<Grades, string>;
declare getHomeroom_teacher: BelongsToGetAssociationMixin<Staff>; declare getHomeroom_teacher: BelongsToGetAssociationMixin<Users>;
declare setHomeroom_teacher: BelongsToSetAssociationMixin<Staff, string>; declare setHomeroom_teacher: BelongsToSetAssociationMixin<Users, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>; declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>; declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>; declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -159,7 +159,7 @@ export class Classes extends Model<
constraints: false, constraints: false,
}); });
db.classes.belongsTo(db.staff, { db.classes.belongsTo(db.users, {
as: 'homeroom_teacher', as: 'homeroom_teacher',
foreignKey: { foreignKey: {
name: 'homeroom_teacherId', name: 'homeroom_teacherId',
@ -191,11 +191,13 @@ export default function (sequelize: Sequelize): typeof Classes {
name: { name: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
logo: { type: DataTypes.TEXT },
section: { section: {
type: DataTypes.TEXT, type: DataTypes.TEXT,

View File

@ -28,13 +28,18 @@ export class CommunicationEvents extends Model<
declare title: string; declare title: string;
declare event_date: string; declare event_date: string;
declare event_type: CommunicationEventType; declare event_type: CommunicationEventType;
declare targetLevel: CreationOptional<string>;
declare roles: CreationOptional<RoleName[]>; declare roles: CreationOptional<RoleName[]>;
declare importHash: CreationOptional<string | null>; declare importHash: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>; declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>; declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>; declare deletedAt: CreationOptional<Date | null>;
declare organizationId: CreationOptional<string>; declare organizationId: CreationOptional<string | null>;
declare campusId: CreationOptional<string | null>; declare campusId: CreationOptional<string | null>;
/** Per-tenant owner (internal alerts are exact-tenant): one leaf is set. */
declare schoolId: CreationOptional<string | null>;
declare classId: CreationOptional<string | null>;
declare canceledEventId: CreationOptional<string | null>;
declare createdById: CreationOptional<string>; declare createdById: CreationOptional<string>;
declare updatedById: CreationOptional<string | null>; declare updatedById: CreationOptional<string | null>;
@ -95,6 +100,11 @@ export default function (sequelize: Sequelize): typeof CommunicationEvents {
type: DataTypes.ENUM(...COMMUNICATION_EVENT_TYPE_VALUES), type: DataTypes.ENUM(...COMMUNICATION_EVENT_TYPE_VALUES),
allowNull: false, allowNull: false,
}, },
targetLevel: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'campus',
},
roles: { roles: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
allowNull: false, allowNull: false,
@ -108,8 +118,11 @@ export default function (sequelize: Sequelize): typeof CommunicationEvents {
createdAt: { type: DataTypes.DATE }, createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE }, updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE },
organizationId: { type: DataTypes.UUID, allowNull: false }, organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
classId: { type: DataTypes.UUID, allowNull: true },
canceledEventId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: false }, createdById: { type: DataTypes.UUID, allowNull: false },
updatedById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true },
}, },

View File

@ -17,6 +17,15 @@ export class ContentCatalog extends Model<
declare payload: unknown; declare payload: unknown;
declare active: CreationOptional<boolean>; declare active: CreationOptional<boolean>;
declare importHash: CreationOptional<string | null>; declare importHash: CreationOptional<string | null>;
/**
* Per-tenant content owner for tenant-scoped content types (the safety quiz).
* Null for shared (org-level) content types. The owning tenant is the most
* specific non-null of (classId, campusId, schoolId, organizationId).
*/
declare organizationId: CreationOptional<string | null>;
declare schoolId: CreationOptional<string | null>;
declare campusId: CreationOptional<string | null>;
declare classId: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>; declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>; declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>; declare deletedAt: CreationOptional<Date | null>;
@ -35,9 +44,11 @@ export default function (sequelize: Sequelize): typeof ContentCatalog {
primaryKey: true, primaryKey: true,
}, },
content_type: { content_type: {
// Not unique: tenant-scoped content types (the safety quiz) have one
// row per owning tenant. Uniqueness is enforced per (content_type,
// tenant) in the service.
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
unique: true,
}, },
payload: { payload: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
@ -53,6 +64,10 @@ export default function (sequelize: Sequelize): typeof ContentCatalog {
allowNull: true, allowNull: true,
unique: true, unique: true,
}, },
organizationId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
classId: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE }, createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE }, updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE },

View File

@ -0,0 +1,96 @@
import {
DataTypes,
Model,
type CreationOptional,
type InferAttributes,
type InferCreationAttributes,
type Sequelize,
type BelongsToGetAssociationMixin,
} from 'sequelize';
import type { Db } from '@/db/types';
import type { Organizations } from './organizations';
import type { Users } from './users';
/**
* Direct 1:1 message between a staff member and a guardian. A conversation is
* the unordered pair {senderId, recipientId}; `studentId` records the student
* the two were connected through. Read access is membership-based (sender or
* recipient only), which keeps every conversation isolated.
*/
export class DirectMessages extends Model<
InferAttributes<DirectMessages>,
InferCreationAttributes<DirectMessages>
> {
declare id: CreationOptional<string>;
declare senderId: string;
declare recipientId: string;
declare studentId: CreationOptional<string | null>;
declare body: string;
declare readAt: CreationOptional<Date | null>;
declare organizationId: CreationOptional<string | null>;
declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>;
declare getSender: BelongsToGetAssociationMixin<Users>;
declare getRecipient: BelongsToGetAssociationMixin<Users>;
declare getStudent: BelongsToGetAssociationMixin<Users>;
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
static associate(db: Db): void {
db.direct_messages.belongsTo(db.users, {
as: 'sender',
foreignKey: { name: 'senderId' },
constraints: false,
});
db.direct_messages.belongsTo(db.users, {
as: 'recipient',
foreignKey: { name: 'recipientId' },
constraints: false,
});
db.direct_messages.belongsTo(db.users, {
as: 'student',
foreignKey: { name: 'studentId' },
constraints: false,
});
db.direct_messages.belongsTo(db.organizations, {
as: 'organization',
foreignKey: { name: 'organizationId' },
constraints: false,
});
}
}
export default function (sequelize: Sequelize): typeof DirectMessages {
DirectMessages.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
senderId: { type: DataTypes.UUID, allowNull: false },
recipientId: { type: DataTypes.UUID, allowNull: false },
studentId: { type: DataTypes.UUID, allowNull: true },
body: { type: DataTypes.TEXT, allowNull: false },
readAt: { type: DataTypes.DATE, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
},
{
sequelize,
modelName: 'direct_messages',
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
return DirectMessages;
}

View File

@ -35,6 +35,9 @@ export class FrameEntries extends Model<
declare deletedAt: CreationOptional<Date | null>; declare deletedAt: CreationOptional<Date | null>;
declare organizationId: CreationOptional<string | null>; declare organizationId: CreationOptional<string | null>;
declare campusId: CreationOptional<string | null>; declare campusId: CreationOptional<string | null>;
/** Per-tenant content owner (one of org/school/campus/class is the leaf). */
declare schoolId: CreationOptional<string | null>;
declare classId: CreationOptional<string | null>;
declare createdById: CreationOptional<string | null>; declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>; declare updatedById: CreationOptional<string | null>;
@ -129,6 +132,8 @@ export default function (sequelize: Sequelize): typeof FrameEntries {
deletedAt: { type: DataTypes.DATE }, deletedAt: { type: DataTypes.DATE },
organizationId: { type: DataTypes.UUID, allowNull: true }, organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true }, campusId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
classId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true }, createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true }, updatedById: { type: DataTypes.UUID, allowNull: true },
}, },

View File

@ -0,0 +1,93 @@
import {
DataTypes,
Model,
type CreationOptional,
type InferAttributes,
type InferCreationAttributes,
type Sequelize,
} from 'sequelize';
import type { Db } from '@/db/types';
import type { BelongsToGetAssociationMixin } from 'sequelize';
import type { Organizations } from './organizations';
import type { Users } from './users';
/**
* Guardian student link (many-to-many). Students and guardians are **users**
* (roles), not SIS entities, so this junction relates two `users` rows: a
* student may have several guardians and a guardian several students.
* `relationship` is an optional label (parent / grandparent / guardian ).
* Tenant-scoped by `organizationId`; queries narrow further via the student's
* class/campus.
*/
export class GuardianStudents extends Model<
InferAttributes<GuardianStudents>,
InferCreationAttributes<GuardianStudents>
> {
declare id: CreationOptional<string>;
declare guardianId: string;
declare studentId: string;
declare relationship: CreationOptional<string | null>;
declare organizationId: CreationOptional<string | null>;
declare createdById: CreationOptional<string | null>;
declare updatedById: CreationOptional<string | null>;
declare createdAt: CreationOptional<Date>;
declare updatedAt: CreationOptional<Date>;
declare deletedAt: CreationOptional<Date | null>;
declare getGuardian: BelongsToGetAssociationMixin<Users>;
declare getStudent: BelongsToGetAssociationMixin<Users>;
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
static associate(db: Db): void {
db.guardian_students.belongsTo(db.users, {
as: 'guardian',
foreignKey: { name: 'guardianId' },
constraints: false,
});
db.guardian_students.belongsTo(db.users, {
as: 'student',
foreignKey: { name: 'studentId' },
constraints: false,
});
db.guardian_students.belongsTo(db.organizations, {
as: 'organization',
foreignKey: { name: 'organizationId' },
constraints: false,
});
db.guardian_students.belongsTo(db.users, { as: 'createdBy' });
db.guardian_students.belongsTo(db.users, { as: 'updatedBy' });
}
}
export default function (sequelize: Sequelize): typeof GuardianStudents {
GuardianStudents.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
guardianId: { type: DataTypes.UUID, allowNull: false },
studentId: { type: DataTypes.UUID, allowNull: false },
relationship: { type: DataTypes.TEXT, allowNull: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
},
{
sequelize,
modelName: 'guardian_students',
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
return GuardianStudents;
}

View File

@ -16,6 +16,7 @@ import attendance_sessions from './attendance_sessions';
import auth_refresh_tokens from './auth_refresh_tokens'; import auth_refresh_tokens from './auth_refresh_tokens';
import campus_attendance_config from './campus_attendance_config'; import campus_attendance_config from './campus_attendance_config';
import campus_attendance_summaries from './campus_attendance_summaries'; import campus_attendance_summaries from './campus_attendance_summaries';
import class_attendance from './class_attendance';
import campuses from './campuses'; import campuses from './campuses';
import class_enrollments from './class_enrollments'; import class_enrollments from './class_enrollments';
import class_subjects from './class_subjects'; import class_subjects from './class_subjects';
@ -25,6 +26,8 @@ import content_catalog from './content_catalog';
import file from './file'; import file from './file';
import frame_entries from './frame_entries'; import frame_entries from './frame_entries';
import grades from './grades'; import grades from './grades';
import guardian_students from './guardian_students';
import direct_messages from './direct_messages';
import message_recipients from './message_recipients'; import message_recipients from './message_recipients';
import messages from './messages'; import messages from './messages';
import organizations from './organizations'; import organizations from './organizations';
@ -35,7 +38,6 @@ import policy_documents from './policy_documents';
import roles from './roles'; import roles from './roles';
import safety_quiz_results from './safety_quiz_results'; import safety_quiz_results from './safety_quiz_results';
import schools from './schools'; import schools from './schools';
import staff from './staff';
import staff_attendance_records from './staff_attendance_records'; import staff_attendance_records from './staff_attendance_records';
import subjects from './subjects'; import subjects from './subjects';
import timetable_periods from './timetable_periods'; import timetable_periods from './timetable_periods';
@ -112,6 +114,7 @@ const models = {
auth_refresh_tokens: auth_refresh_tokens(sequelize), auth_refresh_tokens: auth_refresh_tokens(sequelize),
campus_attendance_config: campus_attendance_config(sequelize), campus_attendance_config: campus_attendance_config(sequelize),
campus_attendance_summaries: campus_attendance_summaries(sequelize), campus_attendance_summaries: campus_attendance_summaries(sequelize),
class_attendance: class_attendance(sequelize),
campuses: campuses(sequelize), campuses: campuses(sequelize),
class_enrollments: class_enrollments(sequelize), class_enrollments: class_enrollments(sequelize),
class_subjects: class_subjects(sequelize), class_subjects: class_subjects(sequelize),
@ -121,6 +124,8 @@ const models = {
file: file(sequelize), file: file(sequelize),
frame_entries: frame_entries(sequelize), frame_entries: frame_entries(sequelize),
grades: grades(sequelize), grades: grades(sequelize),
guardian_students: guardian_students(sequelize),
direct_messages: direct_messages(sequelize),
message_recipients: message_recipients(sequelize), message_recipients: message_recipients(sequelize),
messages: messages(sequelize), messages: messages(sequelize),
organizations: organizations(sequelize), organizations: organizations(sequelize),
@ -131,7 +136,6 @@ const models = {
roles: roles(sequelize), roles: roles(sequelize),
safety_quiz_results: safety_quiz_results(sequelize), safety_quiz_results: safety_quiz_results(sequelize),
schools: schools(sequelize), schools: schools(sequelize),
staff: staff(sequelize),
staff_attendance_records: staff_attendance_records(sequelize), staff_attendance_records: staff_attendance_records(sequelize),
subjects: subjects(sequelize), subjects: subjects(sequelize),
timetable_periods: timetable_periods(sequelize), timetable_periods: timetable_periods(sequelize),

Some files were not shown because too many files have changed in this diff Show More