improved roles and permissions functionality, scopes drilling.
This commit is contained in:
parent
768a13ce29
commit
d1a08e4c3d
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
158
CLAUDE.md
@ -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)
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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`)
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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`.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
17
backend/src/api/controllers/class_attendance.controller.ts
Normal file
17
backend/src/api/controllers/class_attendance.controller.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
26
backend/src/api/controllers/direct_messages.controller.ts
Normal file
26
backend/src/api/controllers/direct_messages.controller.ts
Normal 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);
|
||||
}
|
||||
21
backend/src/api/controllers/guardian_students.controller.ts
Normal file
21
backend/src/api/controllers/guardian_students.controller.ts
Normal 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 });
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
7
backend/src/api/controllers/platform.controller.ts
Normal file
7
backend/src/api/controllers/platform.controller.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
20
backend/src/api/controllers/schools.controller.ts
Normal file
20
backend/src/api/controllers/schools.controller.ts
Normal 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'],
|
||||
});
|
||||
13
backend/src/api/controllers/scope.controller.ts
Normal file
13
backend/src/api/controllers/scope.controller.ts
Normal 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);
|
||||
}
|
||||
@ -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'] });
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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}%` })),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}%` })),
|
||||
|
||||
@ -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}%` })),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
269
backend/src/db/api/schools.ts
Normal file
269
backend/src/db/api/schools.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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. */
|
||||
|
||||
157
backend/src/db/api/users-search.test.ts
Normal file
157
backend/src/db/api/users-search.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
43
backend/src/db/migrations/20260612020000-add-class-scope.ts
Normal file
43
backend/src/db/migrations/20260612020000-add-class-scope.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
};
|
||||
49
backend/src/db/migrations/20260612030000-add-tenant-logo.ts
Normal file
49
backend/src/db/migrations/20260612030000-add-tenant-logo.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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}%` },
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
88
backend/src/db/models/class_attendance.ts
Normal file
88
backend/src/db/models/class_attendance.ts
Normal 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;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
|
||||
@ -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 },
|
||||
|
||||
96
backend/src/db/models/direct_messages.ts
Normal file
96
backend/src/db/models/direct_messages.ts
Normal 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;
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
|
||||
93
backend/src/db/models/guardian_students.ts
Normal file
93
backend/src/db/models/guardian_students.ts
Normal 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;
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user