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/
*/build/
.claude
.DS_Store
*.tsbuildinfo
.env
@ -10,5 +9,5 @@ node_modules/
*.env.*
!.env.example
!*.env.example
.claude
CLAUDE.md
.codex
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).
Associations: `belongsTo` organization, campus, class (`classes`, as `class`),
class_subject (`class_subjects`), taken_by (`staff`), createdBy/updatedBy (users); `hasMany`
class_subject (`class_subjects`), taken_by (`users`), createdBy/updatedBy (users); `hasMany`
`attendance_records` as `attendance_records_attendance_session`. `findBy`/`GET /:id` eager-load
`attendance_records_attendance_session`, organization, campus, class, class_subject, and
taken_by in a single `Promise.all`.
List filters (`AttendanceSessionsFilter`): `id`, `notes` (iLike), `session_dateRange`,
`session_type`, `campus` (id or name, `|`-separated), `class` (id or name), `class_subject`
(id or status), `taken_by` (id or `employee_number`), `organization`, `createdAtRange`, plus
(id or status), `taken_by` (id or email), `organization`, `createdAtRange`, plus
`field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes
@ -94,4 +94,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`,
`classes`, `class_subjects`, `campuses`, `staff`, `permissions.md`.
`classes`, `class_subjects`, `campuses`, `users`, `permissions.md`.

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

View File

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

View File

@ -21,6 +21,8 @@ Location:
`paramStr`).
- `src/middlewares/``authenticate` (passport), `checkPermissions`,
`csrf-origin`, `error-handler`, `upload`.
- `src/commands/` — CLI/maintenance entrypoints. Commands are API-layer
adapters: parse/run the operation, call BLL services, and own process output.
Responsibilities:
@ -40,10 +42,9 @@ The API layer must not:
Every `/api` route is JWT-authenticated at the mount (`authenticated = passport.authenticate('jwt', { session: false })`) **except** the intentionally public surface:
- the `/api/auth/*` public endpoints (sign-in / refresh / sign-out, password reset, email verification, OAuth — the authenticated sub-routes such as `/me` apply passport per route);
- `GET /api/public/campuses`;
- `GET /api/public/content-catalog/:contentType`.
- `GET /api/public/campuses`.
No tenant-owned mutable data is exposed publicly. Authorization is then by permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions(<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)
@ -56,7 +57,7 @@ Location:
Responsibilities:
- Own workflows, transactions, and coordination across repositories.
- Apply tenant, role, campus, and permission rules.
- Apply tenant, scope, campus, and permission rules.
- Map DB records to response DTOs; validate and normalize inputs.
- Accept typed inputs and return typed values/DTOs.
@ -75,6 +76,7 @@ Location:
- `src/db/api/` — one `*DBApi` class per entity (the repository layer).
- `src/db/models/` — Sequelize models.
- `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`.
- `src/db/reset.ts`, `src/db/umzug.ts`, and other DB operational helpers.
- `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`,
`CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`.
@ -151,10 +153,10 @@ Most modules are assembled from shared factories/helpers — keep them that way.
`findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to
`db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
`autocompleteByField`).
- **Feature service (BLL)** = reuse shared helpers: tenant/role access in
- **Feature service (BLL)** = reuse shared helpers: tenant/scope access in
`services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`,
`hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`,
`campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …);
`hasGlobalAccess`, `requireUserId`, `hasFeaturePermission`,
`scopeDimensionWhere`, `assertAuthenticatedTenantUser`, …);
validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`,
`requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts`
(`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`;
@ -200,8 +202,17 @@ silently using insecure defaults.
## Enforcement & verification
- `src/shared/architecture/import-boundaries.test.ts` enforces the import
direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL
edge cases and the one DAL→BLL leak are capped by ceilings that must not grow.
direction. Every production `.ts` file must be assigned to a layer (test files,
declarations, and `test-utils/` are excluded). The test resolves alias and
relative project imports, treats all `src/db/**` files as DAL, forbids
unapproved cross-layer edges, and verifies exact allowlists so stale exceptions
are removed instead of silently accumulating.
- Current exact architecture exceptions are:
- `auth/auth.ts -> @/db/api/users` and
`middlewares/check-permissions.ts -> @/db/api/roles` for API edge wiring.
- `services/auth.ts -> express`, `services/file.ts -> express`, and
`services/file.ts -> @/middlewares/upload` for remaining HTTP-in-BLL cases.
- `db/api/file.ts -> @/services/file` for the file-storage deletion bridge.
- ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the
already-clean invariants at lint time (API→DAL, model/DAL/shared purity).
- `npm run typecheck`, `npm run lint`, `npm test` are the verification gates;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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);
`organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name);
`grades` (name, code, description); `subjects` (name, code, description);
`staff` (employee_number, job_title); `classes` (name, section); `timetables`
`users` (firstName, lastName, phoneNumber, email); `classes` (name, section); `timetables`
(name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks);
`assessments` (name, instructions); `assessment_results` (remarks);
`messages` (subject, body); `message_recipients` (recipient_label, destination).

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`)
- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleName(currentUser?)`
— resolve scope/role from the current user.
- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleScope(currentUser?)`
— resolve tenant/scope from the current user.
- `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`.
- `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id
or throw `ForbiddenError`.
- `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user
has both an id and an organization.
- `hasRoleAccess(currentUser, roleNames)``true` for `globalAccess` users or those
holding one of `roleNames`.
- `campusScope(currentUser, tenantWideRoleNames)` — returns `{}` for tenant-wide/global
users, else `{ campusId }` restricting to the user's campus.
- `hasFeaturePermission(currentUser, permission)` — resolves effective
permissions, including `custom_permissions`, `custom_permissions_filter`, and
the `globalAccess` exclusions for personal workflow permissions.
- `scopeDimensionWhere(currentUser, model)` — derives school/campus/class
constraints from the active scope.
### Validation helpers (`src/services/shared/validate.ts`)

View File

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

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 |
| `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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -82,6 +82,14 @@ export async function me(req: Request, res: Response): Promise<void> {
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> {
const payload = await AuthService.passwordReset(
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 CommunicationsService from '@/services/communications';
export async function listParentMessages(
req: Request,
res: Response,
): 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);
function routeParam(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? '';
return value ?? '';
}
export async function listEvents(req: Request, res: Response): Promise<void> {
@ -38,3 +21,29 @@ export async function createEvent(req: Request, res: Response): Promise<void> {
);
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);
}
/** 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(
req: Request,
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);
}
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> {
const payload = await PolicyAcknowledgmentsService.acknowledge(
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);
}
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> {
await Service.create(req.body.data, req.currentUser, true, hostFromReferer(req));
res.status(200).send(true);
const result = await Service.create(
req.body.data,
req.currentUser,
true,
hostFromReferer(req),
);
res.status(200).send(result ?? true);
}
export async function createOwnerWithOrganization(
req: Request,
res: Response,
): Promise<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> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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 organizationId: 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 updatedById: CreationOptional<string | null>;
@ -129,6 +132,8 @@ export default function (sequelize: Sequelize): typeof FrameEntries {
deletedAt: { type: DataTypes.DATE },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
schoolId: { type: DataTypes.UUID, allowNull: true },
classId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
},

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

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