roles and permissions implementation

This commit is contained in:
Dmitri 2026-06-12 06:55:35 +02:00
parent 376776620e
commit caa7cf0d0f
361 changed files with 14865 additions and 10688 deletions

View File

@ -31,6 +31,7 @@ npm run lint # ESLint only
npm run test # Vitest unit tests
npm run test:e2e # Playwright smoke tests (no backend)
npm run test:e2e:content # Playwright tests (requires backend running)
npm run test:e2e:content -- --grep "accessibility" # Accessibility tests only
```
### Root-level
@ -47,24 +48,41 @@ docker compose down -v # Stop and remove (including DB data)
### Quick Start (Dev Mode)
Prerequisites: PostgreSQL running locally with database `schoolchain_dev` and `backend/.env` configured.
Prerequisites: PostgreSQL running locally with database `schoolchain_dev` (user: `postgres`, password: `postgres`).
```bash
# Terminal 1 - Backend (port 8080)
cd backend
export $(grep -v '^#' .env | xargs) && npm run dev
cd backend && npm run dev
# Terminal 2 - Frontend (port 3000)
cd frontend
npm run dev
cd frontend && npm run dev
```
- Frontend: http://localhost:3000
- Backend API: http://localhost:8080/api-docs/
- Login: `admin@flatlogic.com` / `flatlogicAdmin123!`
Note: Use `npm run start` (not `npm run dev`) for first run to execute migrations and seeders.
### Seed Users (one per role)
Seeded by `backend/src/db/seeders/*` from `shared/constants/seed-fixtures.ts`. Passwords are
hardcoded in the seeder (see table below).
| Email | Name | Role | Scope | Password |
|---|---|---|---|---|
| `admin@flatlogic.com` | Mr. Alex Morgan | `super_admin` | system | `flatlogicAdmin123!` |
| `system_admin@flatlogic.com` | Ms. Jordan Chen | `system_admin` | system | `flatlogicAdmin123!` |
| `owner@flatlogic.com` | Mrs. Patricia Hayes | `owner` | organization | `flatlogicUser123!` |
| `superintendent@flatlogic.com` | Dr. Michael Torres | `superintendent` | organization | `flatlogicUser123!` |
| `director@flatlogic.com` | Dr. Sarah Williams | `director` | campus | `flatlogicUser123!` |
| `office_manager@flatlogic.com` | Ms. Lisa Park | `office_manager` | campus | `flatlogicUser123!` |
| `teacher@flatlogic.com` | Mrs. Emily Johnson | `teacher` | campus | `flatlogicUser123!` |
| `support_staff@flatlogic.com` | Mr. Marcus Davis | `support_staff` | campus | `flatlogicUser123!` |
| `student@flatlogic.com` | Emma Clark | `student` | external | `flatlogicUser123!` |
| `guardian@flatlogic.com` | Mr. Robert Clark | `guardian` | external | `flatlogicUser123!` |
All belong to the seeded company **Demo Academy**; campus-scoped/external users are on the **Tigers** campus. `super_admin`/`system_admin` carry `globalAccess` (no org/campus).
## Architecture
### Three-Layer Pattern (Both Frontend and Backend)
@ -109,13 +127,14 @@ Import direction: `API → Business → Data`. Never skip layers. Cross-cutting
7. **Avoid hardcoded constants**: Add to `backend/src/shared/constants/` or `frontend/src/shared/constants/`
8. **Documentation matters**: Update docs after each task; create docs for new modules
9. **Tests matter**: Update tests after each task; create tests for new functionality
10. **English only**: All documentation, comments, code, and content must be in English (except TODOs or internal plans)
## Documentation Entry Points
- Frontend architecture: `frontend/docs/frontend-architecture.md`
- Backend architecture: `backend/docs/backend-architecture.md`
- Database schema: `backend/docs/database-schema.md` (regenerate after schema changes)
- Integration plan: `docs/full-integration-refactor-plan.md`
- **Backlog / remaining work: `docs/backlog.md`** — the single source for deferred work and open gaps (endpoint wiring, RBAC residuals, design-gated UIs, Phase 4/5 items, file/test/a11y). Consult it before starting work or closing gaps, and keep it updated. (The former sequenced integration plan is retired; its history is in git.)
- VM deployment: `docs/deployment-vm.md`
- Docker deployment: `docs/deployment-docker.md`

View File

@ -1,13 +1 @@
PORT=8080
SECRET_KEY=local_dev_secret_change_me
# Database (local PostgreSQL)
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=schoolchain_dev
DB_USER=postgres
DB_PASS=postgres
SEED_ADMIN_PASSWORD=flatlogicAdmin123!
SEED_USER_PASSWORD=flatlogicUser123!
SEED_ADMIN_EMAIL=admin@flatlogic.com
PORT=8080

View File

@ -25,6 +25,8 @@ AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_MAX_AGE_MS=900000
AUTH_REFRESH_TOKEN_MAX_AGE_MS=1209600000
# Retention grace before the `db:cleanup-tokens` job deletes expired refresh-token rows (default 7 days).
AUTH_REFRESH_TOKEN_RETENTION_MS=604800000
AUTH_COOKIE_DOMAIN=
# Seed-only local credentials. Do not use production passwords here.
@ -35,8 +37,6 @@ SEED_USER_PASSWORD=replace_with_local_seed_password
# Optional external integrations.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
EMAIL_FROM=School Chain Manager <app@example.com>
EMAIL_HOST=
EMAIL_PORT=587

View File

@ -59,9 +59,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
- `importHash` (unique), `organizationId`, `createdById`, `updatedById`, timestamps.
Associations: `belongsTo` organization, createdBy/updatedBy (users); `hasMany`
`classes_academic_year` (classes), `timetables_academic_year` (timetables),
`fee_plans_academic_year` (fee_plans). `findBy`/`GET /:id` eager-load
`classes_academic_year`, `timetables_academic_year`, `fee_plans_academic_year`, and
`classes_academic_year` (classes), `timetables_academic_year` (timetables). `findBy`/`GET /:id` eager-load
`classes_academic_year`, `timetables_academic_year`, and
`organization` in a single `Promise.all`.
List filters (`AcademicYearsFilter`): `id`, `name`, `calendarStart`+`calendarEnd` (matches rows
@ -83,4 +82,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `timetables`,
`fee_plans`, `organizations`, `permissions.md`.
`organizations`, `permissions.md`.

View File

@ -62,9 +62,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
- `importHash` (unique), `organizationId`, `assessmentId`, `studentId`, `createdById`,
`updatedById`, timestamps.
Associations: `belongsTo` organization, assessment (assessments), student (students),
createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load `organization`, `assessment`, and
`student` in a single `Promise.all`.
Associations: `belongsTo` organization, assessment (assessments),
createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load `organization` and `assessment` in a single `Promise.all`.
List filters (`AssessmentResultsFilter`): `id`, `remarks`, `scoreRange`, `active`,
`grade_letter`, `assessment` (id or assessment `name`, `|`-separated, applied as an `include`
@ -87,5 +86,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `assessments`, `students`,
`organizations`, `permissions.md`.
- Generic-CRUD contract: `backend-architecture.md`; related slices: `assessments`, `organizations`, `permissions.md`.

View File

@ -5,7 +5,7 @@
`attendance_records` is the per-student attendance entry within an attendance session — the
present/absent/late/excused mark, optional minutes late, and remarks. It is a generic-CRUD
slice assembled from the shared factories and belongs to one `attendance_sessions` row and one
`students` row.
row.
This is the generic student/session attendance entity; staff attendance is the separate
`staff_attendance` slice (documented elsewhere).
@ -70,7 +70,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
`createdById`, `updatedById`, timestamps (all UUID FKs nullable).
Associations: `belongsTo` organization, attendance_session (`attendance_sessions`),
student (`students`), createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load organization,
student (), createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load organization,
attendance_session, and student in a single `Promise.all`.
List filters (`AttendanceRecordsFilter`): `id`, `remarks` (iLike), `minutes_lateRange`,
@ -95,4 +95,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_sessions`,
`students`, `permissions.md`.
`permissions.md`.

View File

@ -94,4 +94,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`,
`classes`, `class_subjects`, `campuses`, `staff`, `students`, `permissions.md`.
`classes`, `class_subjects`, `campuses`, `staff`, `permissions.md`.

View File

@ -0,0 +1,85 @@
# Audio Library
Workstream 13 — a flexible classroom-timer sound library. A row is one of three
**kinds**: an uploaded `file`, an external `url`, or a synthesized `recipe`.
## Purpose
`director` / `office_manager` / `teacher` add library entries; any campus staff
(`director`, `office_manager`, `teacher`, `support_staff`) can pick one to play in
the classroom timer. The existing **built-in timer sounds stay hardcoded global
defaults** for every organization — they are served from the (global)
`content_catalog` (`classroomTimerSounds`) and synthesized client-side, so they
are not duplicated here. New library entries are **campus-scoped**.
The **"Generate"** button in the timer creates a `recipe` row: a JSON set of
synthesis parameters played purely via the Web Audio API (no file, no network).
Until an AI key is wired, the recipe is produced by a local stub
(`business/audio-files/generate.ts`); only that function's body changes when the
key lands — the persistence, playback and library wiring are the same.
## Entity
`audio_files`: `title`, `kind` (`file` | `url` | `recipe`), `url` (nullable —
set for `file`/`url`), `recipe` (nullable JSONB — set for `recipe`), `is_default`
(false for campus rows; reserved for future platform-global rows), nullable
`organizationId` + `campusId`. A null `organizationId` denotes a global row
visible to everyone. Exactly one of `url` / `recipe` is populated, matching
`kind` (validated in the service).
For a `file` row, the binary is uploaded first through the JWT-authenticated file
subsystem (`POST /api/file/upload/...`, with the Workstream 7 per-file ownership
check) and `url` references it. A `url` row holds an external link. A `recipe`
row never touches the file subsystem.
## Routes (`/api/audio_files`)
- `GET /` — list the caller's campus rows **plus** global defaults
(`organizationId` null). Requires `READ_AUDIO_FILES`.
- `POST /` — add a `file` / `url` / `recipe` row (campus-scoped). Requires
`MANAGE_AUDIO_FILES`. Body `{ data: { kind, title, url? , recipe? } }`.
- `PUT /:id`, `DELETE /:id` — edit/remove an own-organization row (never a
global default). Requires `MANAGE_AUDIO_FILES`.
## Authorization
- `READ_AUDIO_FILES` — all four campus roles (director via full access).
- `MANAGE_AUDIO_FILES``director`, `office_manager`, `teacher` (not
`support_staff`, who is read/play-only).
Non-global users can only manage rows in their own organization; global defaults
(`organizationId` null) are read-only to them. List/scope is enforced in the
service via the shared access helpers.
## Frontend wiring
The classroom-timer sound picker (`business/classroom-timer`) merges the
hardcoded built-ins with the `audio_files` library and groups them by origin —
**Built-in** / **Generated** / **Uploaded** — for clear structure. Playback
branches by kind: `builtin``playBuiltInSound(id)`, `recipe`
`playRecipe(recipe)` (`business/classroom-timer/audio-recipe.ts`), `file`/`url`
`new Audio(url)`. Managers (`canManageAudioFiles`) see a **Generate** button and
a delete affordance on their own rows; global defaults are read-only.
## Tests
- **Unit** (`npm test`): `audio-access.test.ts` (visibility/management rules) and
`shared/constants/audio-files.test.ts` (the `file`/`url`/`recipe` kinds +
`isAudioFileKind`).
- **Frontend unit** (`vitest`): `business/audio-files/selectors.test.ts`
(`canManageAudioFiles`) and `generate.test.ts` (the local recipe stub shape).
- **Seeded e2e** (`frontend/tests/e2e/audio-files.seeded.e2e.ts`,
`npm run test:e2e:content`): create/persist + same-campus read, `support_staff`
read-only, and external-role lockout.
## Open / deferred
- **Binary `file` upload UI** — the typed upload client is still to build, and
the download check must record a `file` row (or exempt audio) first: today
`assertCanDownloadFile` denies any `privateUrl` with no tracked `file` row, and
the standalone `/file/upload/:table/:field` path does not create one. `recipe`
and external `url` rows are unaffected (no `/file/download`).
- **AI generation** — swap the local `generateSoundRecipe` stub for a real model
call once an AI key is available; the rest of the pipeline is unchanged.
- If platform-global audio rows are later added, relax the file-download
ownership check for null-organization files so the defaults stream to all.

View File

@ -3,8 +3,8 @@
## Purpose
The auth subsystem owns sign-in, signup, the current-user profile contract
(`GET /api/auth/me`), password reset / email verification, OAuth (Google /
Microsoft) sign-in, and the permission enforcement model. This document covers
(`GET /api/auth/me`), password reset / email verification, OAuth (Google)
sign-in, and the permission enforcement model. This document covers
the profile and permission concerns; the HttpOnly cookie session transport
(access/refresh tokens, rotation, CSRF/origin, sign-out) is documented in
`backend/docs/cookie-auth.md`.
@ -21,7 +21,9 @@ tokens, or raw Sequelize model objects.
factory).
- Service (BLL): `src/services/auth.ts` (class `Auth`, default export
`AuthService`) with DTO shapes in `src/services/auth.types.ts`.
- Passport strategies: `src/auth/auth.ts` (JWT, Google, Microsoft).
- Passport strategies: `src/auth/auth.ts` (JWT via `passport-jwt`; Google via
the maintained, typed **`passport-google-oauth20`**). OAuth is wired for future
use and is not surfaced in the current UI.
- Cookie helpers: `src/auth/cookies.ts` (used for the session transport;
see `cookie-auth.md`).
- Permission middleware: `src/middlewares/check-permissions.ts`
@ -35,7 +37,7 @@ tokens, or raw Sequelize model objects.
models joined for the profile.
- Shared used: `shared/config` (auth/cookie/OAuth config), `shared/jwt`
(`jwtSign`), `shared/constants/auth.ts`, `shared/constants/roles.ts`
(role/product-role mapping), `shared/errors/*` (`ForbiddenError`,
(role definitions, scopes, names), `shared/errors/*` (`ForbiddenError`,
`ValidationError`), `services/email/*` (verification / reset / invitation
emails).
@ -75,15 +77,10 @@ OAuth endpoints:
- `GET /api/auth/signin/google/callback` (Passport `google`,
`failureRedirect: '/login'`) -> sets session cookies and redirects to
`config.uiUrl` (no token query parameters).
- `GET /api/auth/signin/microsoft` -> redirects to Microsoft
(`passport.authenticate('microsoft', { scope: ['https://graph.microsoft.com/user.read openid'], state })`).
- `GET /api/auth/signin/microsoft/callback` (Passport `microsoft`,
`failureRedirect: '/login'`) -> sets session cookies and redirects to
`config.uiUrl`.
OAuth users are resolved by `db.users.findOrCreate({ where: { email, provider } })`
in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or
`profile._json.userPrincipalName`.
in `src/auth/auth.ts`; the Google email comes from the typed
`profile.emails[0].value`.
## Access Rules
@ -95,11 +92,14 @@ in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or
- `checkPermissions(permission)` allows the request if any of:
1. self-access bypass — `currentUser.id` equals `req.params.id` or
`req.body.id`;
2. the user's `custom_permissions` include `permission`;
3. the effective role's permissions include `permission`. The effective
role is the user's `app_role`, or the cached seeded `Public` role
(`SPECIAL_ROLE_NAMES.PUBLIC`) when there is no assigned role. The
`Public` role is fetched once at module load and cached.
2. global-access bypass — the user's `app_role.globalAccess` is `true`
(the system-scope roles `super_admin` / `system_admin`), which pass any
permission;
3. the user's `custom_permissions` include `permission`;
4. the effective role's permissions include `permission`. The effective
role is the user's `app_role`, or the cached seeded `guest` role
(`ROLE_NAMES.GUEST`) when there is no assigned role. The `guest` role is
fetched once at module load and cached.
- A denied request is passed `new ValidationError('auth.forbidden')`.
- `checkCrudPermissions(name)` derives the permission as
`${METHOD}_${ENTITY}` from the HTTP method and entity name, where the
@ -123,12 +123,13 @@ in `src/auth/auth.ts`; the Microsoft email comes from `profile._json.mail` or
`AuthService.currentUserProfile` returns (built from `findProfileById`):
- `id`, `email`, `firstName`, `lastName`
- `id`, `email`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName`
- `organizationId`
- `organizations``OrganizationDto` `{ id, name }` or `null`
- `app_role``RoleDto` `{ id, name, globalAccess }` or `null`
- `productRole` — a `PRODUCT_ROLE_VALUES` value
(`teacher` | `para` | `office` | `director` | `superintendent`)
- `app_role``RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name`
is one of the 11 first-class role names and `scope` is its scope
(`system` | `organization` | `campus` | `external` | `guest`); the frontend
derives the UI role from `app_role.name`. There is no separate `productRole`.
- `staffProfile``StaffProfileDto`
`{ id, employee_number, job_title, staff_type, status, organizationId,
campusId, userId }` or `null` (first row of `staff_user`)
@ -141,10 +142,14 @@ Note: the profile payload does not include a `phoneNumber` field
(`findProfileById` does not select it and `currentUserProfile` does not return
it).
`productRole` resolution order (`getProductRole`): generated backend role name
via `GENERATED_ROLE_TO_PRODUCT_ROLE`, then staff type via
`STAFF_TYPE_TO_PRODUCT_ROLE`, else `PRODUCT_ROLE_VALUES.TEACHER`. Mappings live
in `src/shared/constants/roles.ts`.
Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts`
`ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by
`db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the
two system roles, `globalAccess: true`. The preset permission matrix grants
`owner` / `superintendent` / `director` every permission, `office_manager` /
`teacher` / `support_staff` read-only entity permissions, and `student` /
`guardian` / `guest` none; `super_admin` / `system_admin` need no rows (they
bypass via `globalAccess`). Per-user `custom_permissions` extend a user's grants.
Signup / signin behavior (`src/services/auth.ts`):
@ -180,4 +185,4 @@ None yet (no auth unit/e2e test under `backend/src`).
- Cookie session transport: `backend/docs/cookie-auth.md`.
- Frontend: `frontend/docs/auth-integration.md`.
- Role / product-role constants: `src/shared/constants/roles.ts`.
- Role constants (definitions, scopes, names): `src/shared/constants/roles.ts`.

View File

@ -35,6 +35,16 @@ The API layer must not:
- Contain tenant/role/permission/workflow rules or DTO mapping.
- Run database queries.
### Authentication and public routes
Every `/api` route is JWT-authenticated at the mount (`authenticated = passport.authenticate('jwt', { session: false })`) **except** the intentionally public surface:
- the `/api/auth/*` public endpoints (sign-in / refresh / sign-out, password reset, email verification, OAuth — the authenticated sub-routes such as `/me` apply passport per route);
- `GET /api/public/campuses`;
- `GET /api/public/content-catalog/:contentType`.
No tenant-owned mutable data is exposed publicly. Authorization is then by permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions(<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`).
## Layer 2: Business Logic (BLL)
Location:
@ -134,9 +144,9 @@ Most modules are assembled from shared factories/helpers — keep them that way.
Factories: `services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts` (generic
over the repository's entity types — no casts). 23 of 26 entities use them;
entities with genuinely different behavior (`users` invitations, `documents`
DTO responses, `permissions` no-`globalAccess` queries) stay hand-written.
over the repository's entity types — no casts). 18 of 21 entities use them;
entities with genuinely different behavior (`users` invitations,
`permissions` no-`globalAccess` queries) stay hand-written.
- **Repository (DAL)** = entity-specific `create`/`update`/`bulkImport`/`findBy`/
`findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to
`db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
@ -161,6 +171,32 @@ Centralized — see `backend/docs/error-handling.md`. Handlers/services throw an
`AppError` subclass; the terminal `error-handler` middleware turns it into the
`{ message, code?, details? }` JSON body the frontend `ApiError` consumes.
### Global error handlers
The server registers process-level handlers in `src/index.ts` to prevent crashes
from unhandled errors:
- `process.on('uncaughtException')` — catches synchronous errors
- `process.on('unhandledRejection')` — catches unhandled promise rejections
These log the error and allow the server to continue running. This protects
against crashes from misconfigured external services (e.g., SMTP without
credentials) or unexpected async failures.
### Production credential guards
Development defaults (DB credentials, SECRET_KEY) are hardcoded in
`shared/constants/app.ts` for local development convenience. However, these
defaults are **never applied in production-like environments**:
- `shared/config/index.ts`: `requiredEnvWithDevDefault()` throws if `SECRET_KEY`
is missing when `NODE_ENV` is `production` or `dev_stage`.
- `db/models/index.ts`: `validateProductionDbConfig()` throws if any `DB_*`
credential is missing in production-like environments.
This ensures the server fails fast with a clear error message rather than
silently using insecure defaults.
## Enforcement & verification
- `src/shared/architecture/import-boundaries.test.ts` enforces the import

View File

@ -25,7 +25,7 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_
## Access Rules
- All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`).
- Mutations (`PUT` config / summary) additionally require manage access (`assertCanManageCampusAttendance`): the user must either hold one of `CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager, finance officer) or have a derived product role in `CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES` (office, director, superintendent). Global-access roles pass `hasRoleAccess`.
- Mutations (`PUT` config / summary) additionally require manage access (`assertCanManageCampusAttendance`): the user must hold one of `CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES` (`super_admin`, `system_admin`, `owner`, `superintendent`, `director`, `office_manager`). Global-access roles pass `hasRoleAccess`.
- Campus-key access (`assertCanAccessCampusKey`): tenant-wide roles (`CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` — super admin, admin, platform owner) may access any campus key. Other users may only access the campus key derived from their own profile (campus code/name, or staff profile campus code/name, normalized via `normalizeCampusKey`); a mismatch or missing campus key throws `ForbiddenError`.
- The frontend does not send organization, campus UUID, creator, updater, or label fields. The backend derives them from the authenticated user (`requireOrganizationId`, `getCampusId`, `getDisplayName`, `currentUser.id`).
@ -47,6 +47,12 @@ Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_
- Summary date range filtering uses `requiredIsoDate` on `startDate`/`endDate` and applies `Op.gte` / `Op.lte` on `attendance_date`.
- Invalid campus keys, dates, or summary payloads throw `ValidationError`; access failures throw `ForbiddenError`.
## Source-of-truth contract (Workstream 12)
Per the customer decision (2026-06-11), the **source of truth for campus attendance is manual entry by the `office_manager`** (and the higher campus/tenant roles), via the `PUT` config/summary endpoints guarded by the `FILL_ATTENDANCE` permission. There is no automatic derivation from student-level records.
**Import from external office applications is deferred:** the customer expects to import these aggregates from other office apps in future, but the specific applications are not yet known, so no import endpoint is built now. When an application is chosen, add a dedicated import endpoint (server-side validated, same tenant/campus scoping) alongside the manual path; until then manual entry is the only writer. No frontend-only attendance source exists — every UI value traces to a `campus_attendance_summaries` row.
## Tests
None yet (no `*.test.ts` under `backend/src` references this slice).

View File

@ -50,7 +50,7 @@ false), `active` (BOOLEAN, not null, default false), `importHash` (unique, nulla
`organizationId` (UUID, nullable), audit fields `createdById` / `updatedById`, and
`createdAt` / `updatedAt` / `deletedAt`. The model is `paranoid` (soft delete) with
`freezeTableName`. Associations include `belongsTo` organization, createdBy, updatedBy, and `hasMany`
students, staff, classes, timetables, attendance_sessions, invoices, messages, documents (all keyed
staff, classes, timetables, attendance_sessions, messages (all keyed
on `campusId`).
## Behavior / Notes

View File

@ -72,8 +72,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
`createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`.
Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy
(users); `hasMany` `students_campus`, `staff_campus`, `classes_campus`, `timetables_campus`,
`attendance_sessions_campus`, `invoices_campus`, `messages_campus`, `documents_campus` (all keyed on
(users); `hasMany` `staff_campus`, `classes_campus`, `timetables_campus`,
`attendance_sessions_campus`, `messages_campus`, `documents_campus` (all keyed on
`campusId`, `constraints: false`).
`findBy` (backing `GET /:id`) returns the plain campus plus all eight `hasMany` collections and the
@ -103,5 +103,4 @@ None yet.
- Public read surface: `campus-catalog.md` (`GET /api/public/campuses`, shares the
`src/db/models/campuses.ts` model but not the `src/db/api/campuses.ts` repository).
- Generic-CRUD contract: `backend-architecture.md`.
- Related slices: `students`, `staff`, `classes`, `timetables`, `attendance_sessions`, `invoices`,
`messages`, `documents` (all child records keyed on `campusId`), `permissions.md`.
- Related slices: `staff`, `classes`, `timetables`, `attendance_sessions`, `messages` (all child records keyed on `campusId`), `permissions.md`.

View File

@ -2,7 +2,7 @@
## Purpose
`class_enrollments` is the per-organization join between `students` and `classes` — it records a
`class_enrollments` is the per-organization join between and `classes` — it records a
student's enrollment in a class with its own dates and status. It is a generic-CRUD slice
assembled from the shared factories; the backend is the source of truth for enrollment records.
@ -63,9 +63,9 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
- `importHash` (unique), `organizationId`, `classId`, `studentId`, `createdById`, `updatedById`,
timestamps.
Associations: `belongsTo` organization, class (classes), student (students),
Associations: `belongsTo` organization, class (classes),
createdBy/updatedBy (users). This model declares no `hasMany`. `findBy`/`GET /:id` eager-load
organization, class and student in a single `Promise.all` (the class association is exposed on
organization and class in a single `Promise.all` (the class association is exposed on
the output as `class`).
List filters (`ClassEnrollmentsFilter`): `id`, `class` (id or name, `|`-separated), `student` (id
@ -87,5 +87,5 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `students`, `classes`,
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`,
`permissions.md`.

View File

@ -9,7 +9,7 @@ The communications slice exposes product-focused endpoints for parent messages a
- Service (BLL): `src/services/communications.ts` (+ `src/services/communications.types.ts`). Contains validation, scope resolution, and DTO mappers.
- Repository (DAL): queries run through `db.messages`, `db.message_recipients`, and `db.communication_events` inside the service (no separate `db/api` file).
- Models: `src/db/models/communication_events.ts`; plus the existing `src/db/models/messages.ts` and `src/db/models/message_recipients.ts` (used by the parent-message flow).
- Shared used: `db/with-transaction.ts`, `services/shared/access.ts`, `services/shared/validate.ts` (`nullableString`), `shared/object.ts` (`isRecord`), `shared/constants/communications.ts` (channels, audiences, statuses, recipient types, event-type values, parent-message categories, manager/tenant-wide role lists), `shared/constants/roles.ts` (`PRODUCT_ROLE_VALUES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
- Shared used: `db/with-transaction.ts`, `services/shared/access.ts`, `services/shared/validate.ts` (`nullableString`), `shared/object.ts` (`isRecord`), `shared/constants/communications.ts` (channels, audiences, statuses, recipient types, event-type values, parent-message categories, manager/tenant-wide role lists), `shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
## API
All routes require JWT authentication.
@ -41,7 +41,7 @@ Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event
## Data Contract
- Parent message input (`ParentMessageInput`): `recipientName` (required non-empty string), `messageText` (required non-empty string), `category` (optional; mapped to one of `behavior`, `event`, `progress`, `general`, defaulting to `general`).
- Event input (`EventInput`): `title` (required), `date` (required, stored to `event_date`), `type` (required; must be one of `meeting`, `drill`, `event`, `deadline`), `roles` (optional array of product-role values; an empty/missing array defaults to `['teacher','para','office','director']`; invalid values throw `ValidationError`).
- Event input (`EventInput`): `title` (required), `date` (required, stored to `event_date`), `type` (required; must be one of `meeting`, `drill`, `event`, `deadline`), `roles` (optional array of role names; an empty/missing array defaults to `['teacher','support_staff','office_manager','director']`; invalid values throw `ValidationError`).
- `communication_events` model: `title` (text), `event_date` (DATEONLY), `event_type` (ENUM of the four types), `roles` (JSONB, default `[]`), `organizationId` (not null), nullable `campusId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, `belongsTo` associations to `organizations`, `campuses`, `users` (createdBy, updatedBy).
- List pagination: both lists use `resolvePagination(limit, page)`.

View File

@ -9,6 +9,11 @@ reads, stores, or sends auth tokens manually. This document covers the cookie
session transport, refresh rotation, CSRF/origin protection, and sign-out; the
profile and permission model is documented in `backend/docs/auth-profile.md`.
The interactive OpenAPI spec (`/api-docs`) documents the cookie model under a
`cookieAuth` security scheme (the HttpOnly access cookie) and the full auth
surface (`@openapi` JSDoc in `src/routes/auth.ts`); the `UserProfile` response
schema is shared with `/me`, sign-in, and refresh.
## Slice Files (by layer)
- Route: `src/routes/auth.ts` (mounted at `/api/auth` in `src/index.ts`).
@ -45,8 +50,8 @@ Base path `/api/auth`.
tokens are a no-op (still clears cookies and returns `204`).
- `GET /api/auth/me` (JWT-authenticated) -> `200` the current user profile,
authenticated from the access cookie.
- OAuth callbacks (`/signin/google/callback`, `/signin/microsoft/callback`) set
session cookies and redirect to `config.uiUrl` without token query parameters.
- The OAuth callback (`/signin/google/callback`) sets session cookies and
redirects to `config.uiUrl` without token query parameters.
## Access Rules
@ -147,9 +152,36 @@ OAuth/email credentials.
configured allow-list.
- Refresh-token reuse triggers family revocation.
## Operational maintenance (refresh-token retention)
Refresh-token rows are persistent (the table is **not** paranoid) and rotation
revokes-and-replaces rather than deleting, so expired/revoked rows accumulate.
A maintenance command physically deletes rows that expired before a retention
grace window:
```bash
npm run db:cleanup-tokens # dev (tsx)
node dist/db/cleanup-refresh-tokens.js # prod (built; or npm run db:cleanup-tokens:prod)
```
- **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The
command deletes rows whose `expiresAt` is older than `now - retentionMs`.
- **Why it is safe:** a row past `expiresAt` can no longer be presented (its
cookie is expired) and is no longer needed for reuse-detection, so deleting it
— revoked or not — does not affect valid sessions. The grace window keeps
recently-expired rows for short-term forensics.
- **Scheduling:** run it from cron or the platform scheduler (e.g. daily). It is
idempotent, logs an observable summary line (`[refresh-token-maintenance] …`),
and exits non-zero on failure.
- Logic lives in `services/refresh-token-maintenance.ts`
(`cleanupExpiredRefreshTokens` + the pure `computeRefreshTokenCutoff`), backed
by `AuthRefreshTokensDBApi.deleteExpiredBefore`.
## Tests
None yet (no auth unit/e2e test under `backend/src`).
- `services/refresh-token-maintenance.test.ts` — the pure retention cutoff
(`computeRefreshTokenCutoff`): window subtraction, non-positive/invalid window
→ cutoff = now, and cutoff ≤ now.
## Related

View File

@ -1,17 +1,17 @@
# Database Schema
> Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth.
> Regenerate after schema changes. Last generated: 2026-06-09.
> Regenerate after schema changes. Last generated: 2026-06-11.
## Overview
- **Engine:** PostgreSQL via **Sequelize 6** (models in `backend/src/db/models`, typed data access in `backend/src/db/api`).
- **Models:** 38 tables.
- **Models:** 35 tables.
- **Primary keys:** every table has a `uuid` `id` (default `UUIDV4`).
- **Soft delete:** all tables are `paranoid` — rows are flagged with `deletedAt` instead of being physically removed.
- **Timestamps:** `createdAt` / `updatedAt` are managed automatically.
- **Audit:** `createdById` / `updatedById` reference `users` (aliases `createdBy` / `updatedBy`).
- **Multi-tenancy:** tenant-owned tables carry `organizationId` and are scoped to the current user's organization in `db/api` (see `full-integration-refactor-plan.md`, tenant boundary workstream).
- **Multi-tenancy:** tenant-owned tables carry `organizationId` and are scoped to the current user's organization in `db/api` (the tenant boundary is enforced via `db/api/shared/repository.ts``findOwnedByPk`/`tenantWhere`).
- **Import idempotency:** `importHash` (unique) deduplicates seeded/imported rows.
### Type notes
@ -21,12 +21,12 @@ Types below are the SQL column types. A few Sequelize types are returned as JS `
## Domains
- **Tenancy & Access:** `organizations`, `users`, `roles`, `permissions`
- **Campuses & People:** `campuses`, `students`, `guardians`, `staff`
- **Campuses & People:** `campuses`, `staff`
- **Academics:** `academic_years`, `grades`, `subjects`, `classes`, `class_enrollments`, `class_subjects`, `timetables`, `timetable_periods`, `assessments`, `assessment_results`
- **Attendance:** `attendance_sessions`, `attendance_records`, `campus_attendance_config`, `campus_attendance_summaries`, `staff_attendance_records`
- **Finance / Billing:** `fee_plans`, `invoices`, `payments`
- **Communication:** `messages`, `message_recipients`, `communication_events`
- **Content & Product modules:** `content_catalog`, `documents`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`
- **Content & Product modules:** `content_catalog`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results`
- **Policy & Audio:** `policy_documents`, `policy_acknowledgments`, `audio_files`
- **System:** `file`, `auth_refresh_tokens`
## Relationship graph (foreign keys)
@ -38,12 +38,10 @@ erDiagram
organizations ||--o{ academic_years : "organization"
organizations ||--o{ assessment_results : "organization"
assessments ||--o{ assessment_results : "assessment"
students ||--o{ assessment_results : "student"
organizations ||--o{ assessments : "organization"
class_subjects ||--o{ assessments : "class_subject"
organizations ||--o{ attendance_records : "organization"
attendance_sessions ||--o{ attendance_records : "attendance_session"
students ||--o{ attendance_records : "student"
organizations ||--o{ attendance_sessions : "organization"
campuses ||--o{ attendance_sessions : "campus"
classes ||--o{ attendance_sessions : "class"
@ -58,7 +56,6 @@ erDiagram
organizations ||--o{ campuses : "organization"
organizations ||--o{ class_enrollments : "organization"
classes ||--o{ class_enrollments : "class"
students ||--o{ class_enrollments : "student"
organizations ||--o{ class_subjects : "organization"
classes ||--o{ class_subjects : "class"
subjects ||--o{ class_subjects : "subject"
@ -70,28 +67,14 @@ erDiagram
staff ||--o{ classes : "homeroom_teacher"
organizations ||--o{ communication_events : "organization"
campuses ||--o{ communication_events : "campus"
organizations ||--o{ documents : "organization"
campuses ||--o{ documents : "campus"
organizations ||--o{ fee_plans : "organization"
academic_years ||--o{ fee_plans : "academic_year"
grades ||--o{ fee_plans : "grade"
organizations ||--o{ frame_entries : "organization"
campuses ||--o{ frame_entries : "campus"
organizations ||--o{ grades : "organization"
organizations ||--o{ guardians : "organization"
students ||--o{ guardians : "student"
organizations ||--o{ invoices : "organization"
campuses ||--o{ invoices : "campus"
students ||--o{ invoices : "student"
fee_plans ||--o{ invoices : "fee_plan"
organizations ||--o{ message_recipients : "organization"
messages ||--o{ message_recipients : "message"
organizations ||--o{ messages : "organization"
campuses ||--o{ messages : "campus"
users ||--o{ messages : "sent_by"
organizations ||--o{ payments : "organization"
invoices ||--o{ payments : "invoice"
staff ||--o{ payments : "received_by"
organizations ||--o{ personality_quiz_results : "organization"
campuses ||--o{ personality_quiz_results : "campus"
users ||--o{ personality_quiz_results : "user"
@ -104,8 +87,6 @@ erDiagram
organizations ||--o{ staff_attendance_records : "organization"
campuses ||--o{ staff_attendance_records : "campus"
users ||--o{ staff_attendance_records : "user"
organizations ||--o{ students : "organization"
campuses ||--o{ students : "campus"
organizations ||--o{ subjects : "organization"
organizations ||--o{ timetable_periods : "organization"
timetables ||--o{ timetable_periods : "timetable"
@ -120,6 +101,13 @@ erDiagram
organizations ||--o{ users : "organizations"
organizations ||--o{ walkthrough_checkins : "organization"
campuses ||--o{ walkthrough_checkins : "campus"
organizations ||--o{ policy_documents : "organization"
campuses ||--o{ policy_documents : "campus"
policy_documents ||--o{ policy_acknowledgments : "policyDocument"
organizations ||--o{ policy_acknowledgments : "organization"
users ||--o{ policy_acknowledgments : "user"
organizations ||--o{ audio_files : "organization"
campuses ||--o{ audio_files : "campus"
```
## Table reference
@ -148,8 +136,6 @@ _Relations:_
- **has many** `academic_years` as `academic_years_organization` (FK `organizationId`)
- **has many** `grades` as `grades_organization` (FK `organizationId`)
- **has many** `subjects` as `subjects_organization` (FK `organizationId`)
- **has many** `students` as `students_organization` (FK `organizationId`)
- **has many** `guardians` as `guardians_organization` (FK `organizationId`)
- **has many** `staff` as `staff_organization` (FK `organizationId`)
- **has many** `classes` as `classes_organization` (FK `organizationId`)
- **has many** `class_enrollments` as `class_enrollments_organization` (FK `organizationId`)
@ -158,14 +144,10 @@ _Relations:_
- **has many** `timetable_periods` as `timetable_periods_organization` (FK `organizationId`)
- **has many** `attendance_sessions` as `attendance_sessions_organization` (FK `organizationId`)
- **has many** `attendance_records` as `attendance_records_organization` (FK `organizationId`)
- **has many** `fee_plans` as `fee_plans_organization` (FK `organizationId`)
- **has many** `invoices` as `invoices_organization` (FK `organizationId`)
- **has many** `payments` as `payments_organization` (FK `organizationId`)
- **has many** `assessments` as `assessments_organization` (FK `organizationId`)
- **has many** `assessment_results` as `assessment_results_organization` (FK `organizationId`)
- **has many** `messages` as `messages_organization` (FK `organizationId`)
- **has many** `message_recipients` as `message_recipients_organization` (FK `organizationId`)
- **has many** `documents` as `documents_organization` (FK `organizationId`)
#### `users`
@ -194,6 +176,7 @@ Authentication identities. `email` is required (login + primary contact). Belong
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `app_roleId` | uuid | yes | — | FK |
| `campusId` | uuid | yes | — | FK, campus scope for campus-bound roles |
_Relations:_
@ -203,16 +186,18 @@ _Relations:_
- **has many** `messages` as `messages_sent_by` (FK `sent_byId`)
- **belongs to** `roles` as `app_role` (FK `app_roleId`)
- **belongs to** `organizations` as `organizations` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **has many** `file` as `avatar` (FK `belongsToId`)
#### `roles`
Named permission sets (RBAC). Linked to permissions M:N; `globalAccess` grants cross-tenant access.
Named permission sets (RBAC), the 11 first-class roles. Linked to permissions M:N; `globalAccess` grants cross-tenant access (system roles).
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `name` | text | yes | — | |
| `scope` | enum | no | — | `system` \| `organization` \| `campus` \| `external` \| `guest` |
| `globalAccess` | boolean | no | false | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
@ -275,80 +260,13 @@ A physical or online campus belonging to one organization. Parent of students, s
_Relations:_
- **has many** `students` as `students_campus` (FK `campusId`)
- **has many** `staff` as `staff_campus` (FK `campusId`)
- **has many** `classes` as `classes_campus` (FK `campusId`)
- **has many** `timetables` as `timetables_campus` (FK `campusId`)
- **has many** `attendance_sessions` as `attendance_sessions_campus` (FK `campusId`)
- **has many** `invoices` as `invoices_campus` (FK `campusId`)
- **has many** `messages` as `messages_campus` (FK `campusId`)
- **has many** `documents` as `documents_campus` (FK `campusId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
#### `students`
Enrolled students. Belong to a campus and organization; have guardians, enrollments, attendance, results, invoices.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `student_number` | text | yes | — | |
| `first_name` | text | yes | — | |
| `last_name` | text | yes | — | |
| `gender` | enum | yes | — | |
| `date_of_birth` | timestamptz | yes | — | |
| `enrollment_date` | timestamptz | yes | — | |
| `status` | enum | yes | — | |
| `email` | text | yes | — | |
| `phone` | text | yes | — | |
| `address` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `campusId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **has many** `guardians` as `guardians_student` (FK `studentId`)
- **has many** `class_enrollments` as `class_enrollments_student` (FK `studentId`)
- **has many** `attendance_records` as `attendance_records_student` (FK `studentId`)
- **has many** `invoices` as `invoices_student` (FK `studentId`)
- **has many** `assessment_results` as `assessment_results_student` (FK `studentId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **has many** `file` as `photo` (FK `belongsToId`)
#### `guardians`
Guardians/contacts linked to a student.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `full_name` | text | yes | — | |
| `relationship` | enum | yes | — | |
| `phone` | text | yes | — | |
| `email` | text | yes | — | |
| `address` | text | yes | — | |
| `primary_contact` | boolean | no | false | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `organizationId` | uuid | yes | — | FK |
| `studentId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `students` as `student` (FK `studentId`)
#### `staff`
Staff members, optionally linked to a `user` account; can be homeroom teacher, subject teacher, attendance taker, payment receiver.
@ -376,7 +294,6 @@ _Relations:_
- **has many** `classes` as `classes_homeroom_teacher` (FK `homeroom_teacherId`)
- **has many** `class_subjects` as `class_subjects_teacher` (FK `teacherId`)
- **has many** `attendance_sessions` as `attendance_sessions_taken_by` (FK `taken_byId`)
- **has many** `payments` as `payments_received_by` (FK `received_byId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `user` (FK `userId`)
@ -407,7 +324,6 @@ _Relations:_
- **has many** `classes` as `classes_academic_year` (FK `academic_yearId`)
- **has many** `timetables` as `timetables_academic_year` (FK `academic_yearId`)
- **has many** `fee_plans` as `fee_plans_academic_year` (FK `academic_yearId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
#### `grades`
@ -432,7 +348,6 @@ Grade levels (with `sort_order`).
_Relations:_
- **has many** `classes` as `classes_grade` (FK `gradeId`)
- **has many** `fee_plans` as `fee_plans_grade` (FK `gradeId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
#### `subjects`
@ -516,7 +431,6 @@ _Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `classes` as `class` (FK `classId`)
- **belongs to** `students` as `student` (FK `studentId`)
#### `class_subjects`
@ -656,7 +570,6 @@ _Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `assessments` as `assessment` (FK `assessmentId`)
- **belongs to** `students` as `student` (FK `studentId`)
### Attendance
@ -715,7 +628,6 @@ _Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `attendance_sessions` as `attendance_session` (FK `attendance_sessionId`)
- **belongs to** `students` as `student` (FK `studentId`)
#### `campus_attendance_config`
@ -799,104 +711,6 @@ _Relations:_
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `user` (FK `userId`)
### Finance / Billing
#### `fee_plans`
Fee/tuition plans (billing cycle, total amount) for a grade in an academic year. Invoices are generated from these.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `name` | text | yes | — | |
| `billing_cycle` | enum | yes | — | |
| `total_amount` | decimal | yes | — | |
| `active` | boolean | no | false | |
| `notes` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `academic_yearId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `gradeId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **has many** `invoices` as `invoices_fee_plan` (FK `fee_planId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`)
- **belongs to** `grades` as `grade` (FK `gradeId`)
#### `invoices`
Invoices issued to a student (amounts, status) — optionally from a fee_plan.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `invoice_number` | text | yes | — | |
| `issue_date` | timestamptz | yes | — | |
| `due_date` | timestamptz | yes | — | |
| `subtotal` | decimal | yes | — | |
| `discount_amount` | decimal | yes | — | |
| `tax_amount` | decimal | yes | — | |
| `total_amount` | decimal | yes | — | |
| `balance_due` | decimal | yes | — | |
| `status` | enum | yes | — | |
| `notes` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `campusId` | uuid | yes | — | FK |
| `fee_planId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `studentId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **has many** `payments` as `payments_invoice` (FK `invoiceId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `students` as `student` (FK `studentId`)
- **belongs to** `fee_plans` as `fee_plan` (FK `fee_planId`)
- **has many** `file` as `attachments` (FK `belongsToId`)
#### `payments`
Payments against an invoice (amount, method, proof file).
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `receipt_number` | text | yes | — | |
| `paid_at` | timestamptz | yes | — | |
| `amount` | decimal | yes | — | |
| `method` | enum | yes | — | |
| `reference_code` | text | yes | — | |
| `notes` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `invoiceId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `received_byId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `invoices` as `invoice` (FK `invoiceId`)
- **belongs to** `staff` as `received_by` (FK `received_byId`)
- **has many** `file` as `proof` (FK `belongsToId`)
### Communication
#### `messages`
@ -999,34 +813,6 @@ Product content catalog.
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
#### `documents`
Polymorphic document records with a `file` attachment, scoped to organization/campus.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `entity_type` | enum | yes | — | |
| `entity_reference` | text | yes | — | |
| `name` | text | yes | — | |
| `category` | enum | yes | — | |
| `uploaded_at` | timestamptz | yes | — | |
| `notes` | text | yes | — | |
| `importHash` | varchar | yes | — | unique, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
| `campusId` | uuid | yes | — | FK |
| `organizationId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **has many** `file` as `file` (FK `belongsToId`)
#### `frame_entries`
Product-module "frame" entries.
@ -1183,6 +969,91 @@ _Relations:_
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **belongs to** `users` as `user` (FK `userId`)
### Policy & Audio
#### `policy_documents`
Unified store for the Safety Protocols and Handbook & Policies pages (Workstream 11). `category` selects the page; `tag` is the finer sub-category.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `title` | text | no | — | |
| `body` | text | yes | — | |
| `category` | enum | no | — | `safety_protocol` \| `handbook_policy` |
| `tag` | varchar | yes | — | sub-category / safety card icon |
| `author` | varchar | yes | — | display name of the creating user |
| `steps` | jsonb | yes | — | author-filled procedure steps (safety) |
| `autism_considerations` | jsonb | yes | — | author-filled considerations (safety) |
| `version` | integer | no | 1 | bumped on title/body/steps/considerations change |
| `active` | boolean | no | true | |
| `importHash` | varchar | yes | — | unique, audit |
| `organizationId` | uuid | yes | — | FK |
| `campusId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
- **has many** `policy_acknowledgments` (FK `policyDocumentId`)
#### `policy_acknowledgments`
Per-user, per-version acknowledgment of a policy document. Not paranoid (no soft delete).
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `policyDocumentId` | uuid | no | — | FK |
| `version` | integer | no | — | acknowledged document version |
| `userId` | uuid | no | — | FK |
| `acknowledgedAt` | timestamptz | no | — | |
| `organizationId` | uuid | yes | — | FK |
| `campusId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
_Unique:_ (`userId`, `policyDocumentId`, `version`) — `policy_acknowledgments_user_document_version_unique`.
_Relations:_
- **belongs to** `policy_documents` as `policyDocument` (FK `policyDocumentId`)
- **belongs to** `users` as `user` (FK `userId`)
- **belongs to** `organizations` as `organization` (FK `organizationId`)
#### `audio_files`
Flexible classroom-timer sound library (Workstream 13). Each row is one `kind`; exactly one of `url` / `recipe` is populated.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `title` | text | no | — | |
| `kind` | enum | no | `file` | `file` \| `url` \| `recipe` |
| `url` | varchar | yes | — | set for `file` / `url` |
| `recipe` | jsonb | yes | — | synthesis params; set for `recipe` |
| `is_default` | boolean | no | false | platform-global default |
| `importHash` | varchar | yes | — | unique, audit |
| `organizationId` | uuid | yes | — | FK; null = global default |
| `campusId` | uuid | yes | — | FK |
| `createdById` | uuid | yes | — | FK, audit |
| `updatedById` | uuid | yes | — | FK, audit |
| `createdAt` | timestamptz | yes | — | audit |
| `updatedAt` | timestamptz | yes | — | audit |
| `deletedAt` | timestamptz | yes | — | audit |
_Relations:_
- **belongs to** `organizations` as `organization` (FK `organizationId`)
- **belongs to** `campuses` as `campus` (FK `campusId`)
### System
#### `file`

View File

@ -1,111 +0,0 @@
# Documents Backend
## Purpose
`documents` stores file/document metadata records (with related `file` uploads) attached to
school entities such as students, staff, classes, invoices, organizations, and campuses. The
slice is hand-written (not the generic CRUD factory): the service returns trimmed DTOs via
`toDocumentDto`, supports CSV export and CSV bulk import, and resolves related `organization`,
`campus`, and `file` on single-record reads.
## Slice Files (by layer)
- Route: `src/routes/documents.ts` (wires CRUD plus `bulk-import`, `count`, `autocomplete`,
`deleteByIds`; applies `checkCrudPermissions('documents')` to every route).
- Controller: `src/api/controllers/documents.controller.ts` (custom — maps DTOs, handles CSV
export and file upload).
- Service (BLL): `src/services/documents.ts` (exports `toDocumentDto`; wraps writes in
transactions; parses CSV buffers for bulk import).
- Repository (DAL): `src/db/api/documents.ts`.
- Model: `src/db/models/documents.ts`.
- Shared used: `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
`autocompleteByField`), `db/api/file.ts` (`FileDBApi.replaceRelationFiles`), `db/utils.ts`
(`Utils.uuid`, `Utils.ilike`), `shared/constants/pagination.ts` (`resolvePagination`),
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `shared/csv.ts` (`toCsv`),
`middlewares/upload.ts` (`processFile`), `middlewares/check-permissions.ts`,
`shared/errors/validation.ts` (`ValidationError`).
## API
All routes are mounted under `/api/documents` and require JWT authentication (mounted with
`authenticated` in `src/index.ts`). Every route additionally passes
`checkCrudPermissions('documents')`, which checks the permission `${METHOD}_DOCUMENTS`
(see `permissions.md`).
- `POST /api/documents` -> `201`, the created document DTO. Request body: `{ data: <DocumentInput> }`.
- `POST /api/documents/bulk-import` -> `200` `true`. Multipart file upload (`processFile`); a CSV
buffer is parsed into rows. Missing file raises `ValidationError('importer.errors.invalidFileEmpty')`.
- `PUT /api/documents/:id` -> `200`, the updated document DTO. The controller calls
`Service.update(req.body.data, req.body.id, ...)` (it reads `req.body.id`, not `req.params.id`).
- `DELETE /api/documents/:id` -> `200` `true`.
- `POST /api/documents/deleteByIds` -> `200` `true`. Request body: `{ data: string[] }`.
- `GET /api/documents` -> `200` `{ rows, count }` where `rows` are DTOs. When `?filetype=csv`,
responds with a CSV attachment of fields `id, entity_reference, name, notes, uploaded_at`.
- `GET /api/documents/count` -> `200` `{ rows: [], count }` (count-only).
- `GET /api/documents/autocomplete` -> `200` array of `{ id, label }` matched on `name`.
- `GET /api/documents/:id` -> `200`, a single record (plain) with eager-resolved `organization`,
`campus`, and `file`. This response is NOT passed through `toDocumentDto`.
## Access Rules
Authorization is by CRUD permission only: `checkCrudPermissions('documents')` requires the
effective role (or a custom per-user permission) to hold `${METHOD}_DOCUMENTS`. There is no
additional role-name gate or owner check inside the service. The self-access bypass in
`check-permissions.ts` (matching `req.params.id`/`req.body.id` to the current user id) does not
meaningfully apply to documents since those ids are document ids, not user ids.
## Tenant Scope
- `findAll` scopes to `currentUser.organizationId` only when the user has a loaded
`organizations` association and an `organizationId`. Callers with `globalAccess` (from
`currentUser.app_role.globalAccess`) have the `organizationId` constraint removed, so they read
across organizations.
- On `create`, the document's organization is forced to `currentUser.organizationId`
(`setOrganization`), regardless of input.
- On `update`, organization is only changed when `data.organization` is provided: global-access
users may set the provided organization; non-global users are pinned back to
`currentUser.organizationId`.
- `campus` is set from input on create and update.
## Data Contract
Model columns (`src/db/models/documents.ts`): `id` (UUID PK), `entity_type` (ENUM: `student`,
`staff`, `class`, `invoice`, `organization`, `campus`, `other`), `entity_reference` (text),
`name` (text), `category` (ENUM: `policy`, `report`, `id`, `medical`, `consent`, `invoice`,
`receipt`, `other`), `uploaded_at` (date), `notes` (text), `importHash` (unique), `createdAt`,
`updatedAt`, `deletedAt` (paranoid soft-delete), `campusId`, `organizationId`, `createdById`,
`updatedById`. Associations: `belongsTo organizations` (as `organization`), `belongsTo campuses`
(as `campus`), `hasMany file` (as `file`, scoped relation upload), `belongsTo users` (as
`createdBy`/`updatedBy`).
`toDocumentDto` exposes only: `id`, `entity_type`, `entity_reference`, `name`, `category`,
`uploaded_at`, `notes`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`,
`updatedAt`. It deliberately omits `importHash`, `deletedAt`, and eager relations.
List filters (`findAll`): `id`, `entity_reference`, `name`, `notes` (all ILIKE),
`uploaded_atRange`, `active`, `entity_type`, `category`, `campus` (filter-only inner join on id
or name, `|`-separated), `organization` (`|`-separated ids), `createdAtRange`, plus `field`/`sort`
ordering (defaults to `createdAt desc`) and `limit`/`page` pagination.
## Behavior / Notes
- All mutations (`create`, `bulkImport`, `update`, `deleteByIds`, `remove`) run inside a manual
Sequelize transaction (`db.sequelize.transaction()`), committing on success and rolling back on
error.
- `update` raises `ValidationError('documentsNotFound')` when the record does not exist.
- Bulk import parses the uploaded CSV (`csv-parser`) into rows, then `bulkCreate`s with
`ignoreDuplicates: true` and per-row `createdAt` staggered by `BULK_IMPORT_TIMESTAMP_STEP_MS`
to preserve ordering. Related files are attached per row via `replaceRelationFiles`.
- The list query selects only scalar columns (no eager org/file load); the `campus` filter join
selects no attributes (filter-only, inner join).
## Tests
None yet (no `documents` unit/e2e test under `src/`).
## Related
- Frontend: `frontend/docs/policies-integration.md` (the handbook/policies workflow reads and
mutates policy documents via `GET /api/documents?category=policy`, `POST`, `PUT`, `DELETE`).
- Backend slices: `permissions.md` (the `${METHOD}_DOCUMENTS` permission gate), and the `file`
upload relation used by `replaceRelationFiles`.

View File

@ -1,89 +0,0 @@
# Fee Plans Backend
## Purpose
`fee_plans` is the per-organization catalogue of fee plans (billing schedules) that invoices can
reference. It is a generic-CRUD slice assembled from the shared factories; the backend is the
source of truth for fee-plan records.
## Slice Files (by layer)
- Route: `src/routes/fee_plans.ts``createCrudRouter(controller, { permission: 'fee_plans' })`.
- Controller: `src/api/controllers/fee_plans.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/fee_plans.ts``createCrudService(DbApi, { notFoundCode: 'fee_plansNotFound' })`.
- Repository (DAL): `src/db/api/fee_plans.ts` (`Fee_plansDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/fee_plans.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`).
## API
The standard generic-CRUD surface (all under `/api/fee_plans`, JWT + `${METHOD}_FEE_PLANS`
permission, all `200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is `name`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `name`, `notes`, `total_amount`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('fee_plans')`, deriving
`READ_FEE_PLANS` / `CREATE_FEE_PLANS` / `UPDATE_FEE_PLANS` / `DELETE_FEE_PLANS` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org).
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `name`, `notes` (TEXT, nullable).
- `billing_cycle` — ENUM `one_time` | `monthly` | `termly` | `annual`.
- `total_amount` — DECIMAL.
- `active` — BOOLEAN, `allowNull: false`, default `false`.
- `importHash` (unique), `academic_yearId`, `organizationId`, `gradeId`, `createdById`,
`updatedById`, timestamps.
Associations: `belongsTo` organization, academic_year, grade, createdBy/updatedBy (users);
`hasMany` `invoices_fee_plan` (invoices). `findBy`/`GET /:id` eager-load `invoices_fee_plan`,
organization, academic_year, grade in a single `Promise.all`.
List filters (`FeePlansFilter`): `id`, `name`, `notes`, `total_amountRange`, `active`,
`billing_cycle`, `academic_year` (id or name, `|`-separated), `grade` (id or name, `|`-separated),
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes
- This slice has a real `active` BOOLEAN column. `create`/`bulkImport` default `active` to
`false`; `update` sets it when provided.
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `findAll` applies the `active` filter twice — once via the shared
`filter.active === true || filter.active === 'true'` coercion and again via a redundant
`if (filter.active) where.active = filter.active` block (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `invoices`, `academic_years`,
`grades`, `permissions.md`.

View File

@ -10,9 +10,16 @@ file DAL when entity relations are persisted, not by the upload endpoint itself.
## Slice Files (by layer)
- Route: `src/routes/file.ts` (thin wiring; `GET /download`, `POST /upload/:table/:field`).
- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`).
- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`). `download`
calls `assertCanDownloadFile` before serving.
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
`downloadGCloud`, `deleteGCloud`, `initGCloud`).
`downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus
`src/services/file-access.ts` (`assertCanDownloadFile`) for the per-file authorization. Both
upload and download require JWT; local handlers reject path traversal. Download enforces a
per-file tenant/ownership check: the file's owning organization (resolved from its `privateUrl`
via the uploader `createdById`) must match the requester's organization, unless the requester
has global access; files with no tracked row are denied. (Upload-side per-file ownership and a
typed frontend upload client are still open — tracked in the file workstream.)
- Repository (DAL): `src/db/api/file.ts` (`FileDBApi``replaceRelationFiles`, `_addFiles`,
`_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports
`@/services/file` to call `deleteGCloud` (see Behavior / Notes).
@ -23,11 +30,16 @@ file DAL when entity relations are persisted, not by the upload endpoint itself.
## API
- `GET /api/file/download?privateUrl=<path>` -> downloads the file. No authentication middleware on
this route. The controller dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or
`NEXT_PUBLIC_BACK_API` is set, otherwise `downloadLocal`.
- Local: missing `privateUrl` -> `404`; otherwise streams via `res.download` from
`config.uploadDir`.
- `GET /api/file/download?privateUrl=<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`.
- Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path
traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download`
from `config.uploadDir`.
- GCloud: serves `${hash}/${privateUrl}` from the bucket; file missing or error -> `404`
`{ message }`.
- `POST /api/file/upload/:table/:field` -> uploads a single file. Requires JWT authentication

View File

@ -34,8 +34,8 @@ 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 Administrator`, `Administrator`, `Platform Owner`, `Tenant Director`,
`Campus Manager`. Enforced by `assertCanEdit` via `hasRoleAccess`; a non-editor gets
capabilities) — `super_admin`, `system_admin`, `owner`, `superintendent`,
`director`. Enforced by `assertCanEdit` via `hasRoleAccess`; a non-editor gets
`ForbiddenError`. Frontend may hide editing controls, but the backend check is authoritative.
## Tenant Scope

View File

@ -58,8 +58,8 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
- `importHash` (unique), `organizationId`, `createdById`, `updatedById`, timestamps.
Associations: `belongsTo` organization, createdBy/updatedBy (users); `hasMany`
`classes_grade` (classes), `fee_plans_grade` (fee_plans). `findBy`/`GET /:id` eager-load
`classes_grade`, `fee_plans_grade`, and `organization` in a single `Promise.all`.
`classes_grade` (classes). `findBy`/`GET /:id` eager-load
`classes_grade`, and `organization` in a single `Promise.all`.
List filters (`GradesFilter`): `id`, `name`, `code`, `description`, `sort_orderRange`, `active`,
`organization` (`|`-separated ids), `createdAtRange`, plus `field`/`sort` ordering and
@ -78,5 +78,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `fee_plans`,
`organizations`, `permissions.md`.
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `organizations`, `permissions.md`.

View File

@ -1,86 +0,0 @@
# Guardians Backend
## Purpose
`guardians` is the per-organization roster of student guardians/contacts, each optionally linked
to a single `student`. It is a generic-CRUD slice assembled from the shared factories; the
backend is the source of truth for guardian records.
## Slice Files (by layer)
- Route: `src/routes/guardians.ts``createCrudRouter(controller, { permission: 'guardians' })`.
- Controller: `src/api/controllers/guardians.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/guardians.ts``createCrudService(DbApi, { notFoundCode: 'guardiansNotFound' })`.
- Repository (DAL): `src/db/api/guardians.ts` (`GuardiansDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/guardians.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `db/utils.ts` (`Utils`).
## API
The standard generic-CRUD surface (all under `/api/guardians`, JWT + `${METHOD}_GUARDIANS`
permission, all `200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is
`full_name`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `full_name`, `phone`, `email`, `address`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('guardians')`, deriving
`READ_GUARDIANS` / `CREATE_GUARDIANS` / `UPDATE_GUARDIANS` / `DELETE_GUARDIANS` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org), and only when
`data.organization` is provided.
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `full_name`, `phone`, `email`, `address` (TEXT, nullable).
- `relationship` — ENUM `mother` | `father` | `guardian` | `other`.
- `primary_contact` — BOOLEAN, `allowNull: false`, default `false`.
- `importHash` (unique), `organizationId`, `studentId`, `createdById`, `updatedById`, timestamps.
Associations: `belongsTo` organization, student, createdBy/updatedBy (users). `findBy`/`GET /:id`
eager-load organization and student in a single `Promise.all`.
List filters (`GuardiansFilter`): `id`, `full_name`, `phone`, `email`, `address`, `relationship`,
`primary_contact`, `student` (id or `student_number`, `|`-separated), `organization` (id list,
`|`-separated), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `GuardiansFilter` accepts an `active` flag and `findAll` filters on an `active` column,
but the model has no `active` column; this filter is currently inert (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `students`, `organizations`,
`permissions.md`.

View File

@ -22,24 +22,26 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
- [`migrations-and-seeders.md`](migrations-and-seeders.md): the Umzug runner, file conventions,
and how to author a migration/seeder.
- [`error-handling.md`](error-handling.md): centralized `AppError` pipeline and error body shape.
- [`test-coverage.md`](test-coverage.md): backend test runner, utilities, and current unit coverage.
## Auth And Access
- [`auth-profile.md`](auth-profile.md): sign-in, profile, `GET /api/auth/me`, OAuth, permission model.
- [`cookie-auth.md`](cookie-auth.md): HttpOnly cookie sessions and refresh rotation.
- [`permissions.md`](permissions.md): the `${METHOD}_${ENTITY}` permission catalog and enforcement.
- [`roles.md`](roles.md): roles entity and role<->permission linkage.
- [`users.md`](users.md): users entity, invitations, and CSV bulk import.
- [`roles.md`](roles.md): the 11 first-class roles (scope, globalAccess) and role<->permission linkage.
- [`users.md`](users.md): users entity, invitations, role policy, provisioning, and CSV bulk import.
## Product Feature Slices
- [`audio-files.md`](audio-files.md): classroom-timer sound library (`file`/`url`/`recipe` kinds).
- [`campus-attendance.md`](campus-attendance.md)
- [`campus-catalog.md`](campus-catalog.md): public campus records and branding.
- [`communications.md`](communications.md)
- [`content-catalog.md`](content-catalog.md)
- [`documents.md`](documents.md)
- [`frame-entries.md`](frame-entries.md)
- [`personality-quiz-results.md`](personality-quiz-results.md)
- [`policy-documents.md`](policy-documents.md): unified Safety Protocols + Handbook & Policies store and per-version acknowledgments.
- [`safety-quiz-results.md`](safety-quiz-results.md)
- [`staff-attendance.md`](staff-attendance.md)
- [`user-progress.md`](user-progress.md)
@ -50,16 +52,13 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
One document per entity (assembled from the shared CRUD factories; identical 9-endpoint surface —
see [`shared-crud-factories.md`](shared-crud-factories.md)).
- People: [`students.md`](students.md), [`staff.md`](staff.md), [`guardians.md`](guardians.md),
[`organizations.md`](organizations.md).
- People: [`staff.md`](staff.md), [`organizations.md`](organizations.md).
- Academics: [`classes.md`](classes.md), [`subjects.md`](subjects.md),
[`class_subjects.md`](class_subjects.md), [`class_enrollments.md`](class_enrollments.md),
[`academic_years.md`](academic_years.md), [`assessments.md`](assessments.md),
[`assessment_results.md`](assessment_results.md), [`grades.md`](grades.md).
- Attendance: [`attendance_sessions.md`](attendance_sessions.md),
[`attendance_records.md`](attendance_records.md).
- Finance: [`invoices.md`](invoices.md), [`payments.md`](payments.md),
[`fee_plans.md`](fee_plans.md).
- Scheduling: [`timetables.md`](timetables.md), [`timetable_periods.md`](timetable_periods.md).
- Messaging: [`messages.md`](messages.md), [`message_recipients.md`](message_recipients.md).
- Access: [`campuses.md`](campuses.md) (authenticated CRUD), [`roles.md`](roles.md).

View File

@ -1,93 +0,0 @@
# Invoices Backend
## Purpose
`invoices` is the per-organization billing-invoice ledger. It is a generic-CRUD slice assembled
from the shared factories; the backend is the source of truth for invoice records.
## Slice Files (by layer)
- Route: `src/routes/invoices.ts``createCrudRouter(controller, { permission: 'invoices' })`.
- Controller: `src/api/controllers/invoices.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/invoices.ts``createCrudService(DbApi, { notFoundCode: 'invoicesNotFound' })`.
- Repository (DAL): `src/db/api/invoices.ts` (`InvoicesDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/invoices.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
`db/api/file.ts` (`replaceRelationFiles` for the `attachments` relation).
## API
The standard generic-CRUD surface (all under `/api/invoices`, JWT + `${METHOD}_INVOICES`
permission, all `200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is
`invoice_number`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `invoice_number`, `notes`, `subtotal`, `discount_amount`, `tax_amount`,
`total_amount`, `balance_due`, `issue_date`, `due_date`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('invoices')`, deriving
`READ_INVOICES` / `CREATE_INVOICES` / `UPDATE_INVOICES` / `DELETE_INVOICES` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org).
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `invoice_number`, `notes` (TEXT, nullable).
- `issue_date`, `due_date` — DATE.
- `subtotal`, `discount_amount`, `tax_amount`, `total_amount`, `balance_due` — DECIMAL.
- `status` — ENUM `draft` | `issued` | `partially_paid` | `paid` | `overdue` | `void`.
- `importHash` (unique), `campusId`, `fee_planId`, `organizationId`, `studentId`, `createdById`,
`updatedById`, timestamps.
Associations: `belongsTo` organization, campus, student, fee_plan, createdBy/updatedBy (users);
`hasMany` `payments_invoice` (payments); `hasMany` file as `attachments` (scoped relation).
`findBy`/`GET /:id` eager-load `payments_invoice`, organization, campus, student, fee_plan,
attachments in a single `Promise.all`.
List filters (`InvoicesFilter`): `id`, `invoice_number`, `notes`, `issue_dateRange`,
`due_dateRange`, `subtotalRange`, `discount_amountRange`, `tax_amountRange`, `total_amountRange`,
`balance_dueRange`, `status`, `campus` (id or name, `|`-separated), `student` (id or
student_number, `|`-separated), `fee_plan` (id or name, `|`-separated), `organization`,
`createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes
- `create`/`bulkImport`/`update` manage the `attachments` file relation via
`FileDBApi.replaceRelationFiles`.
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `InvoicesFilter` accepts an `active` flag the model has no column for; it is currently
inert (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `payments`, `fee_plans`,
`students`, `campuses`, `file.md`, `permissions.md`.

View File

@ -14,10 +14,20 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o
`migrator.up()` + `seeder.up()`.
- Schema snapshot: `src/db/initial-schema.ts` — DDL snapshot derived from the Sequelize models;
the models remain the source of truth.
- Migrations: `src/db/migrations/*.ts` — currently `20260610000000-initial-schema.ts` (creates
the full schema from the snapshot).
- Seeders: `src/db/seeders/*.ts``admin-user`, `user-roles`, `product-campuses`,
`content-catalog` (+ payloads under `seeders/content-catalog-data/`).
- Migrations: `src/db/migrations/*.ts``20260610000000-initial-schema.ts` (creates the full
schema from the snapshot) and `20260610010000-add-role-scope-and-user-campus.ts` (adds the
NOT-NULL `roles.scope` enum and the nullable `users.campusId`). Phase 4 adds
`20260611000000-policy-documents-and-acknowledgments.ts` (the unified policy store + per-version
acknowledgments), `20260611010000-audio-files.ts` (the audio library) +
`20260611060000-audio-files-kinds.ts` (the `kind` enum / nullable `url` / `recipe` JSONB), and
`20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum).
- Seeders: `src/db/seeders/*.ts``admin-user` (the 10 per-role RBAC fixture users),
`user-roles` (the 11 first-class roles, the permission catalog incl. product-feature
permissions, the role->permission matrix, role assignment by user id), `product-campuses`,
`content-catalog` (+ payloads under `seeders/content-catalog-data/`), `rbac-fixtures`
(the company, campus->org ownership, per-user org/campus links, staff profiles), and
`20260611050000-policy-documents-seed.ts` (3 safety protocols + 4 handbook policies). Shared
fixture definitions live in `src/shared/constants/seed-fixtures.ts`.
## Mechanism

View File

@ -29,8 +29,11 @@ permission, all `200`) — see `backend-architecture.md` for the shared contract
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `DELETE /:id` — returns `true`. Gated by the §3.3 relational policy
(`assertCanDeleteOrganization` in `routes/organizations.ts`): only `super_admin` /
`system_admin` / `owner` may delete a company; a `superintendent` is blocked even though it
holds `DELETE_ORGANIZATIONS`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true` (same delete guard).
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is `name`.
@ -44,6 +47,8 @@ permission, all `200`) — see `backend-architecture.md` for the shared contract
`READ_ORGANIZATIONS` / `CREATE_ORGANIZATIONS` / `UPDATE_ORGANIZATIONS` / `DELETE_ORGANIZATIONS`
per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
- Companies are created by the provisioning flow, not this CRUD: creating an `owner` user
auto-creates the organization (`services/users.ts` -> `OrganizationsDBApi.create`).
## Tenant Scope
@ -69,11 +74,9 @@ No ENUM columns.
Associations: `belongsTo` createdBy/updatedBy (users); `hasMany` (all keyed by the child's
`organizationId`): `users_organizations`, `campuses_organization`, `academic_years_organization`,
`grades_organization`, `subjects_organization`, `students_organization`, `guardians_organization`,
`staff_organization`, `classes_organization`, `class_enrollments_organization`,
`grades_organization`, `subjects_organization`, `staff_organization`, `classes_organization`, `class_enrollments_organization`,
`class_subjects_organization`, `timetables_organization`, `timetable_periods_organization`,
`attendance_sessions_organization`, `attendance_records_organization`, `fee_plans_organization`,
`invoices_organization`, `payments_organization`, `assessments_organization`,
`attendance_sessions_organization`, `attendance_records_organization`, `assessments_organization`,
`assessment_results_organization`, `messages_organization`, `message_recipients_organization`,
`documents_organization`. `findBy`/`GET /:id` eager-load all of these in a single `Promise.all`.
@ -96,5 +99,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; tenant scoping: `permissions.md`. Every
per-organization slice references this table via `organizationId` (e.g. `students`, `guardians`,
`staff`, `campuses`).
per-organization slice references this table via `organizationId` (e.g. `staff`, `campuses`).

View File

@ -1,91 +0,0 @@
# Payments Backend
## Purpose
`payments` is the per-organization record of payments received against invoices. It is a
generic-CRUD slice assembled from the shared factories; the backend is the source of truth for
payment records.
## Slice Files (by layer)
- Route: `src/routes/payments.ts``createCrudRouter(controller, { permission: 'payments' })`.
- Controller: `src/api/controllers/payments.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/payments.ts``createCrudService(DbApi, { notFoundCode: 'paymentsNotFound' })`.
- Repository (DAL): `src/db/api/payments.ts` (`PaymentsDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/payments.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
`db/api/file.ts` (`replaceRelationFiles` for the `proof` relation).
## API
The standard generic-CRUD surface (all under `/api/payments`, JWT + `${METHOD}_PAYMENTS`
permission, all `200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is
`receipt_number`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `receipt_number`, `reference_code`, `notes`, `amount`, `paid_at`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('payments')`, deriving
`READ_PAYMENTS` / `CREATE_PAYMENTS` / `UPDATE_PAYMENTS` / `DELETE_PAYMENTS` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org).
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `receipt_number`, `reference_code`, `notes` (TEXT, nullable).
- `paid_at` — DATE.
- `amount` — DECIMAL.
- `method` — ENUM `cash` | `bank_transfer` | `card` | `mobile_money` | `cheque` | `other`.
- `importHash` (unique), `invoiceId`, `organizationId`, `received_byId`, `createdById`,
`updatedById`, timestamps.
Associations: `belongsTo` organization, invoice, received_by (staff), createdBy/updatedBy
(users); `hasMany` file as `proof` (scoped relation). `findBy`/`GET /:id` eager-load
organization, invoice, received_by, proof in a single `Promise.all`.
List filters (`PaymentsFilter`): `id`, `receipt_number`, `reference_code`, `notes`,
`paid_atRange`, `amountRange`, `method`, `invoice` (id or invoice_number, `|`-separated),
`received_by` (id or employee_number, `|`-separated), `organization`, `createdAtRange`, plus
`field`/`sort` ordering and `limit`/`page` pagination.
## Behavior / Notes
- `create`/`bulkImport`/`update` manage the `proof` file relation via
`FileDBApi.replaceRelationFiles`.
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `PaymentsFilter` accepts an `active` flag the model has no column for; it is currently
inert (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `invoices`, `staff`,
`file.md`, `permissions.md`.

View File

@ -22,7 +22,7 @@ authorization middleware checks them per request. This slice manages the permiss
- Shared used: `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`), `db/utils.ts`
(`Utils.uuid`, `Utils.ilike`), `shared/constants/pagination.ts` (`resolvePagination`),
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`),
`shared/constants/roles.ts` (`SPECIAL_ROLE_NAMES`), `shared/csv.ts` (`toCsv`),
`shared/constants/roles.ts` (`ROLE_NAMES`), `shared/csv.ts` (`toCsv`),
`middlewares/upload.ts` (`processFile`), `db/api/roles.ts` (`RolesDBApi`, used by the
middleware), `shared/object.ts` (`isRecord`), `shared/logger.ts`,
`shared/errors/validation.ts`.
@ -57,16 +57,17 @@ service or repository.
`checkCrudPermissions(name)` derives a permission string from the HTTP method and entity name:
`${METHOD_MAP[req.method]}_${name.toUpperCase()}` where `METHOD_MAP` is
`POST→CREATE`, `GET→READ`, `PUT→UPDATE`, `PATCH→UPDATE`, `DELETE→DELETE`. For example a `GET` on
the `users` router requires `READ_USERS`; a `POST` on `documents` requires `CREATE_DOCUMENTS`. It
the `users` router requires `READ_USERS`; a `POST` on `assessments` requires `CREATE_ASSESSMENTS`. It
then delegates to `checkPermissions(permissionName)`.
`checkPermissions(permission)` allows the request when any of the following holds, in order:
1. Self-access bypass: `currentUser.id === req.params.id` or `currentUser.id === req.body.id`.
1. Self-access bypass: read-only — a `GET` whose `currentUser.id === req.params.id` (the
`req.body.id` bypass was removed; profile self-edits go through `/api/auth/profile`).
2. Custom (per-user) permissions: the current user's `custom_permissions` contains a record whose
`name` equals the required permission.
3. Effective-role permissions: the effective role is the user's `app_role` if present, otherwise
the `Public` role (`SPECIAL_ROLE_NAMES.PUBLIC`), which is fetched once at module load via
the `guest` role (`ROLE_NAMES.GUEST`), which is fetched once at module load via
`RolesDBApi.findBy` and cached (`publicRoleCache`); if the cache is empty it is fetched
synchronously as a fallback. `resolveRolePermissions` reads the role's permission names from an
eager-loaded `permissions` array when present, otherwise calls `getPermissions()`. Access is
@ -77,6 +78,21 @@ On denial the middleware logs the role name and the denied permission and calls
a `getPermissions()` method, or a missing/unfetchable `Public` role, surfaces an Internal Server
Error via `next(new Error(...))`.
## Product-feature permissions (§3.2)
Besides the `${METHOD}_${ENTITY}` CRUD permissions, the catalog includes product-feature
permissions defined once in `shared/constants/product-permissions.ts`: a `READ_<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**
`checkPermissions(name)` middleware: page reads and the special actions call it directly
(e.g. `GET /api/frame_entries``READ_FRAME`, `PUT /api/campus_attendance/summaries/...`
`FILL_ATTENDANCE`, `POST /api/safety_quiz_results``TAKE_QUIZ`). Because that middleware honors
`custom_permissions` (step 2 above), a director can extend a single user's feature access by
granting one of these names. Manager-only writes (FRAME/walkthrough/communications/content-catalog
editing, the staff/attendance reports) remain gated by role inside their services until dedicated
`MANAGE_*` permissions are introduced.
## Tenant Scope
None. The permission catalog is global; `findAll` applies no organization filter.

View File

@ -40,8 +40,8 @@ All routes require JWT authentication. Base path mounted at `/api/personality_qu
- `getCurrentUserResult` / `upsertCurrentUserResult`: any authenticated tenant user
(`assertAuthenticatedTenantUser`); each user reads and writes only their own result (filtered by
`userId`).
- `distribution`: restricted to `PERSONALITY_REPORT_ROLE_NAMES` (`Super Administrator`,
`Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or any role with
- `distribution`: restricted to `PERSONALITY_REPORT_ROLE_NAMES` (`super_admin`,
`system_admin`, `owner`, `superintendent`, `director`) or any role with
`globalAccess`, enforced by `hasRoleAccess`; otherwise `ForbiddenError`. The distribution
response contains only `type` and `count` per group — no individual names or answers.

View File

@ -0,0 +1,111 @@
# Policy Documents & Acknowledgments
Workstream 11 — persistence of staff acknowledgment of policy/safety documents.
## Purpose
Campus staff must acknowledge two categories of documents — **Safety Protocols**
(official/government) and **Handbook & Policies** (internal). `director` and
`office_manager` author the documents; all four campus staff roles (`director`,
`office_manager`, `teacher`, `support_staff`) acknowledge them. Acknowledgment is
**per document version**: editing a document bumps its `version`, which requires
re-acknowledgment.
## Frontend wiring
The two existing pages consume this single store (the old generated `documents`
entity it replaced has been removed):
- **Handbook & Policies** (`business/policies`) lists `policy_documents` of
`category = handbook_policy`, mapping the handbook's sub-category to/from `tag`
(`toPolicyViewModel` / `toPolicyDocumentMutationDto`). Management is gated to
owner/superintendent/director/office_manager (`canManagePolicies`, mirroring the
backend grant). Acknowledgment is **persisted** via `policy_acknowledgments`
(`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former
local-state set.
- **Safety Protocols** (`business/safety-protocols`) consumes
`category = safety_protocol`, rendering author-filled `steps` + autism
considerations with a static per-`tag` card icon (fire/shield/heart). It shares
the persistent acknowledgment hooks. Both pages are seeded from
`20260611050000-policy-documents-seed.ts` (safety protocols reuse the former
content-catalog `safetyProtocols` payload; a few handbook policies seed the
handbook page). The Safety Protocols page also has a manager-gated
**structured-authoring** flow (mirrors the F.R.A.M.E. module: header
*New Protocol* button → create form, per-card *Edit*/*Delete*) with
**dynamic** `steps` + `autismConsiderations` rows that add/remove
independently, so each protocol carries its own count
(`useSafetyProtocolsModule` + `SafetyProtocolForm` /
`SafetyDynamicListEditor`; gated by `canManageSafetyProtocols`, which reuses
the policy grant). Title/body/steps/considerations changes bump `version` and
require re-acknowledgment.
## Entities
- `policy_documents` (generic-CRUD entity): `title`, `body`, `category`
(`safety_protocol` | `handbook_policy` — selects the page), `tag` (nullable
finer **sub-category**; the Handbook page maps its
Operations/Behavior/Safety/Communication/Legal categorisation onto it, and the
Safety page uses it to pick the static card icon), `author` (display name of
the **creating user**, set server-side at creation and not changed on update),
`steps` + `autism_considerations` (JSONB string arrays — **author-filled
structured content** for safety protocols; null for handbook policies),
`version` (bumped when `title`/`body`/`steps`/`autism_considerations` change),
`active`, tenant `organizationId` + nullable `campusId`. This is the **single
unified store** for both the Safety Protocols and Handbook & Policies pages
(filter by `?category=` and optionally `?tag=`). The category **list** + icons
are static frontend config; each document's category assignment (`tag`) is DB
data. `author` is derived from the current user's name —
`${name_prefix} firstName lastName` (the honorific title from `users.name_prefix`,
e.g. "Dr. Sarah Williams"), else email.
- `policy_acknowledgments` (per-user): one row per (`userId`, `policyDocumentId`,
`version`), with `acknowledgedAt`. Unique index on those three columns;
acknowledging is idempotent for a given version.
## Routes
- `GET/POST /api/policy_documents`, `PUT/DELETE /api/policy_documents/:id`,
plus the standard generic-CRUD extras — guarded by
`checkCrudPermissions('policy_documents')` (`${METHOD}_POLICY_DOCUMENTS`).
- `GET /api/policy_acknowledgments` (the caller's own acknowledgments) and
`POST /api/policy_acknowledgments` (`{ data: { policyDocumentId } }`
acknowledges the document's **current** version) — both guarded by
`checkPermissions('ACK_POLICY')`.
## Authorization
- `READ_POLICY_DOCUMENTS` — granted to the four campus roles (director via full
access; office_manager/teacher/support via the read-only entity grant).
`student`/`guardian` get no policy-document access.
- `CREATE/UPDATE/DELETE_POLICY_DOCUMENTS``director` (full access) and
`office_manager` (explicit grant in the role seeder). `teacher`/`support_staff`
are read-only.
- `ACK_POLICY` — the four campus roles (a product-feature action permission;
extendable per user via `custom_permissions`).
Tenant/campus scoping is applied in the data layer (`tenantWhere` /
`findOwnedByPk`); acknowledgment reads are additionally restricted to the
caller's own `userId`. A manager-facing acknowledgment-status report (audience
TBD) is a deferred refinement.
## Tests
- **Unit** (`backend/src/shared/constants/policy-documents.test.ts` +
`users.test.ts`, `npm test`): the pure domain rules —
`isPolicyDocumentCategory` validation, the `nextPolicyDocumentVersion`
re-acknowledgment bump, and `formatPersonName` (author rendering).
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
autism considerations) and `business/safety-protocols/selectors.test.ts`
(management grant + draft validation for the authoring form).
- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`,
`npm run test:e2e:content`): document create/persist, manage-vs-read RBAC
(director/office_manager manage; teacher reads but cannot create), idempotent
per-version acknowledgment, version-bump re-acknowledgment, and external-role
lockout.
## Open / deferred
- Acknowledgment-status reporting for managers (who-acknowledged-what) — pending
the report-audience decision.
- The acknowledgment + document-management **UI** is design-gated (see
`docs/backlog.md`).

View File

@ -59,7 +59,9 @@ The standard generic-CRUD surface (all under `/api/roles`, JWT + `${METHOD}_ROLE
Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
- `id` (UUID PK), `name` (TEXT, nullable), `globalAccess` (BOOLEAN, not null, default `false`),
- `id` (UUID PK), `name` (TEXT, nullable), `scope` (ENUM
`system | organization | campus | external | guest`, NOT NULL),
`globalAccess` (BOOLEAN, not null, default `false`),
`importHash` (STRING(255), unique, nullable), `createdById`, `updatedById`,
`createdAt` / `updatedAt` / `deletedAt`.
@ -104,10 +106,15 @@ is `createdAt desc`.
`name ASC` and selects only `id`/`name`.
- Note: `RolesFilter` accepts an `active` flag and `findAll` filters on an `active` column the
`roles` model does not declare; it is currently inert (kept for source accuracy).
- **Seeded globalAccess roles**: The seeder (`20200430130760-user-roles.ts`) sets `globalAccess: true`
for both `Super Administrator` and `Administrator` roles. Users with these roles can access data
across all organizations without an `organizationId` filter. Services use `getOrganizationIdOrGlobal`
and `hasGlobalAccess` from `services/shared/access.ts` to check for and honor global access.
- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the 11 first-class roles from
`ROLE_DEFINITIONS` (`shared/constants/roles.ts`), each with its `scope`. `globalAccess: true` is set
for the two system-scope roles (`super_admin`, `system_admin`); their requests bypass both
per-permission checks (`check-permissions.ts`) and the `organizationId` filter. Org/campus roles
(`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained
to their tenant/campus by scoping; `student`, `guardian`, and the unauthenticated-fallback `guest`
have no entity-CRUD permissions. The seeder also assigns roles to the seeded users and writes the
preset role→permission matrix. Services use `getOrganizationIdOrGlobal` / `hasGlobalAccess`
(`services/shared/access.ts`) to honor global access.
## Tests

View File

@ -18,7 +18,7 @@ role snapshot, and persistence. Each submission is an append (create) — there
- Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts`
(`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`,
`assertAuthenticatedTenantUser`, `hasRoleAccess`, `getDisplayName`); `shared/constants/roles.ts`
(`GENERATED_ROLE_TO_PRODUCT_ROLE`, `PRODUCT_ROLE_VALUES`); `shared/constants/safety-quiz.ts`
(`ROLE_NAMES`); `shared/constants/safety-quiz.ts`
(`SAFETY_QUIZ_REPORT_ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`).
## API
@ -36,8 +36,8 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
- All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`).
- `create`: a staff user creates a result for themselves; ownership fields are filled from the
authenticated user.
- `list`: users holding a role in `SAFETY_QUIZ_REPORT_ROLE_NAMES` (`Super Administrator`,
`Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or any role with
- `list`: users holding a role in `SAFETY_QUIZ_REPORT_ROLE_NAMES` (`super_admin`,
`system_admin`, `owner`, `superintendent`, `director`) or any role with
`globalAccess` (via `hasRoleAccess`) see all org-level results; everyone else sees only their own
rows (filtered by `userId`).
@ -54,8 +54,7 @@ All routes require JWT authentication. Base path mounted at `/api/safety_quiz_re
`score` and `total_questions` (integers); `answers` (an array of integers). Invalid input raises
`ValidationError`.
- On create the backend fills `user_name` from `getDisplayName(currentUser)` and `user_role` from
the product-role mapping (`GENERATED_ROLE_TO_PRODUCT_ROLE`, defaulting to
`PRODUCT_ROLE_VALUES.TEACHER`); `completed_at` is set to the current time. The frontend does not
the user's `app_role.name` (defaulting to `teacher`); `completed_at` is set to the current time. The frontend does not
send name, role, or ownership fields.
- DTO fields: `id`, `quiz_id`, `quiz_title`, `week_of`, `score`, `total_questions`, `answers`,
`user_name`, `user_role`, `completed_at`, `organizationId`, `campusId`, `userId`, `createdAt`,

View File

@ -50,17 +50,13 @@ The searched tables and columns are fixed in the service:
- Text columns (`TABLE_COLUMNS`): `users` (firstName, lastName, phoneNumber, email);
`organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name);
`grades` (name, code, description); `subjects` (name, code, description); `students`
(student_number, first_name, last_name, email, phone, address); `guardians` (full_name, phone,
email, address); `staff` (employee_number, job_title); `classes` (name, section); `timetables`
`grades` (name, code, description); `subjects` (name, code, description);
`staff` (employee_number, job_title); `classes` (name, section); `timetables`
(name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks);
`fee_plans` (name, notes); `invoices` (invoice_number, notes); `payments` (receipt_number,
reference_code, notes); `assessments` (name, instructions); `assessment_results` (remarks);
`messages` (subject, body); `message_recipients` (recipient_label, destination); `documents`
(entity_reference, name, notes).
`assessments` (name, instructions); `assessment_results` (remarks);
`messages` (subject, body); `message_recipients` (recipient_label, destination).
- Numeric columns (`COLUMNS_INT`, cast to varchar before matching): `grades` (sort_order); `classes`
(capacity); `attendance_records` (minutes_late); `fee_plans` (total_amount); `invoices` (subtotal,
discount_amount, tax_amount, total_amount, balance_due); `payments` (amount); `assessments`
(capacity); `attendance_records` (minutes_late); `assessments`
(max_score); `assessment_results` (score).
Text columns match with `Op.iLike '%searchQuery%'`; numeric columns are cast to `varchar` and

View File

@ -7,8 +7,8 @@ from. Instead of copy-pasting a service, controller, router, and repository per
each slice wires its repository through three factories (`createCrudService`,
`createCrudController`, `createCrudRouter`) plus a set of repository and validation
helpers. This document is the canonical reference for the resulting 9-endpoint CRUD
surface; the 23 entity docs point here rather than restating it. Hand-written slices
(e.g. users, documents, roles, permissions, campuses, frame_entries) do not use these
surface; the entity docs point here rather than restating it. Hand-written slices
(e.g. users, roles, permissions, campuses, frame_entries) do not use these
factories.
## Files
@ -19,7 +19,7 @@ factories.
factory), the `CrudControllerService` interface, and the `CrudController` type.
- `src/api/http/crud-router.ts``createCrudRouter` (route-wiring factory).
- `src/db/api/shared/repository.ts` — generic repository helpers (`removeRecord`,
`deleteRecordsByIds`, `autocompleteByField`).
`deleteRecordsByIds`, `autocompleteByField`, `findOwnedByPk`, `tenantWhere`).
- `src/services/shared/access.ts` — tenant/role access helpers.
- `src/services/shared/validate.ts` — input validation helpers.
- `src/services/shared/csv-import.ts``parseCsvRows` CSV-buffer parser.
@ -90,22 +90,27 @@ Note on route order: `GET /count` and `GET /autocomplete` are registered before
`${METHOD_MAP[req.method]}_${name.toUpperCase()}`, where `METHOD_MAP` is
`POST -> CREATE`, `GET -> READ`, `PUT -> UPDATE`, `PATCH -> UPDATE`, `DELETE -> DELETE`,
then delegates to `checkPermissions(permissionName)`. So `createCrudRouter(..., {
permission: 'students' })` enforces `CREATE_STUDENTS` / `READ_STUDENTS` /
`UPDATE_STUDENTS` / `DELETE_STUDENTS` per method. See `permissions.md`.
permission: 'campuses' })` enforces `CREATE_CAMPUSES` / `READ_CAMPUSES` /
`UPDATE_CAMPUSES` / `DELETE_CAMPUSES` per method. See `permissions.md`.
### Repository helpers (`src/db/api/shared/repository.ts`)
Generic over `Model`; cover the methods that are byte-identical across entities, leaving
`create`/`update`/`bulkImport`/`findBy`/`findAll` in each entity repository:
- `removeRecord(model, id, options?)``findByPk` then soft-deletes via `destroy`;
returns the record or `null` when absent.
- `deleteRecordsByIds(model, ids, options?)``findAll` where `id IN ids` then
`destroy` each (within the caller's transaction); returns the records.
- `tenantWhere(currentUser)` — the `{ organizationId }` clause to AND into a query, or `{}`
for a global-access user / no resolvable org. The shared tenant-scoping primitive.
- `findOwnedByPk(model, id, options?)` — tenant-scoped `findOne` by id; returns `null` when
the row is absent **or** belongs to another organization. Used by each entity `update`
(and read-by-id) in place of `findByPk`, so cross-tenant ids are not visible or mutable.
- `removeRecord(model, id, options?)` — soft-deletes via `findOwnedByPk` (tenant-scoped) then
`destroy`; returns the record or `null`.
- `deleteRecordsByIds(model, ids, options?)``findAll` where `id IN ids` AND
`tenantWhere(currentUser)`, then `destroy` each; cross-tenant ids are silently skipped.
- `autocompleteByField(model, field, query, limit, offset, globalAccess, organizationId)`
— returns `{ id, label }[]` from a single label column. Scopes `where.organizationId`
to the tenant unless `globalAccess`; when `query` is set it matches by `id` (via
`Utils.uuid`) or case-insensitive substring (`Utils.ilike`); orders by the field ASC.
— returns `{ id, label }[]` from a single label column. ANDs `organizationId` for non-global
users **and keeps it when a `query` is present** (the query branch merges, it no longer
overwrites the tenant clause); matches by `id` (`Utils.uuid`) or substring (`Utils.ilike`).
### Access helpers (`src/services/shared/access.ts`)
@ -152,9 +157,9 @@ Generic over `Model`; cover the methods that are byte-identical across entities,
## Used By
The generic-CRUD entity slices documented under `backend/docs/` (e.g. `students.md`,
`guardians`, `class_enrollments`, `attendance_records`, `invoices`,
`assessment_results`, and the other CRUD entities). Each route file calls
The generic-CRUD entity slices documented under `backend/docs/` (e.g.
`class_enrollments`, `attendance_records`, `assessment_results`, and the other
CRUD entities). Each route file calls
`createCrudRouter(controller, { permission })`, each controller calls
`createCrudController(service, { csvFields })`, and each service calls
`createCrudService(DbApi, { notFoundCode })`.
@ -168,5 +173,5 @@ None yet.
- `backend-architecture.md` — the three-layer model and module-authoring guidance these
factories implement.
- `permissions.md` — how `checkCrudPermissions` resolves the per-method permission.
- Per-entity slice docs (e.g. `students.md`) for entity-specific repository behavior,
- Per-entity slice docs (e.g. `campuses.md`) for entity-specific repository behavior,
filters, and associations.

View File

@ -52,9 +52,9 @@ Enforced by `visibilityScope` in the service:
(`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`) or users with `globalAccess` see all organization
records; other report-role users are restricted to their own campus (`campusId` from their staff
profile, else unrestricted if no campus resolves).
- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles Super Administrator, Administrator,
Platform Owner, Tenant Director, Campus Manager.
- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = Super Administrator, Administrator, Platform Owner.
- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles super_admin, system_admin,
owner, superintendent, director.
- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = super_admin, system_admin, owner.
- `globalAccess` on the user's app role grants access in any role check (`hasRoleAccess`).
Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or

View File

@ -68,7 +68,7 @@ Model columns (`paranoid`, soft-delete via `deletedAt`):
Associations: `belongsTo` organization, campus, user (a `users` record), createdBy/updatedBy
(users); `hasMany` `classes_homeroom_teacher` (classes via `homeroom_teacherId`),
`class_subjects_teacher` (class_subjects via `teacherId`), `attendance_sessions_taken_by`
(attendance_sessions via `taken_byId`), `payments_received_by` (payments via `received_byId`);
(attendance_sessions via `taken_byId`);
`hasMany` file as `photo` (scoped relation). `findBy`/`GET /:id` eager-load all of these in a
single `Promise.all`.
@ -93,4 +93,4 @@ None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `organizations`, `campuses`,
`classes`, `class_subjects`, `attendance_sessions`, `payments`, `file.md`, `permissions.md`.
`classes`, `class_subjects`, `attendance_sessions`, `file.md`, `permissions.md`.

View File

@ -1,94 +0,0 @@
# Students Backend
## Purpose
`students` is the per-organization student roster. It is a generic-CRUD slice assembled from
the shared factories; the backend is the source of truth for student records.
## Slice Files (by layer)
- Route: `src/routes/students.ts``createCrudRouter(controller, { permission: 'students' })`.
- Controller: `src/api/controllers/students.controller.ts``createCrudController(service, { csvFields })`.
- Service (BLL): `src/services/students.ts``createCrudService(DbApi, { notFoundCode: 'studentsNotFound' })`.
- Repository (DAL): `src/db/api/students.ts` (`StudentsDBApi`) — entity-specific
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
delegate to `db/api/shared/repository.ts`.
- Model: `src/db/models/students.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
`db/api/file.ts` (`replaceRelationFiles` for the photo relation).
## API
The standard generic-CRUD surface (all under `/api/students`, JWT + `${METHOD}_STUDENTS`
permission, all `200`) — see `backend-architecture.md` "Module authoring" / the planned
`shared-crud-factories.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
param), returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is
`student_number`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `student_number`, `first_name`, `last_name`, `email`, `phone`, `address`,
`date_of_birth`, `enrollment_date`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('students')`, deriving
`READ_STUDENTS` / `CREATE_STUDENTS` / `UPDATE_STUDENTS` / `DELETE_STUDENTS` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
## Tenant Scope
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
role clears the org filter (sees all tenants).
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
organization for `globalAccess` users (otherwise it stays the caller's org).
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`):
- `id` (UUID PK), `student_number`, `first_name`, `last_name`, `email`, `phone`, `address`
(all TEXT, nullable).
- `gender` — ENUM `male` | `female` | `other` | `prefer_not_to_say`.
- `status` — ENUM `prospect` | `enrolled` | `inactive` | `graduated` | `transferred`.
- `date_of_birth`, `enrollment_date` — DATE.
- `importHash` (unique), `campusId`, `organizationId`, `createdById`, `updatedById`, timestamps.
Associations: `belongsTo` organization, campus, createdBy/updatedBy (users); `hasMany`
`guardians_student`, `class_enrollments_student`, `attendance_records_student`,
`invoices_student`, `assessment_results_student`; `hasMany` file as `photo` (scoped relation).
`findBy`/`GET /:id` eager-load all of these in a single `Promise.all`.
List filters (`StudentsFilter`): `id`, `student_number`, `first_name`, `last_name`, `email`,
`phone`, `address`, `date_of_birthRange`, `enrollment_dateRange`, `gender`, `status`, `campus`
(id or name, `|`-separated), `organization`, `createdAtRange`, plus `field`/`sort` ordering and
`limit`/`page` pagination.
## Behavior / Notes
- `create`/`bulkImport`/`update` manage the `photo` file relation via
`FileDBApi.replaceRelationFiles`.
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
- Note: `StudentsFilter` accepts an `active` flag the model has no column for; it is currently
inert (kept for source accuracy).
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`; related slices: `guardians`,
`class_enrollments`, `attendance_records`, `invoices`, `assessment_results`, `campuses`,
`file.md`, `permissions.md`.

View File

@ -0,0 +1,208 @@
# Backend Test Coverage
## Test Runner
The backend uses Node.js built-in test runner (`node:test`) with `tsx` for TypeScript execution.
```bash
npm run test # Run all tests
npm run verify # Typecheck + lint + tests
```
## Test Structure
Tests are colocated with source files using the `.test.ts` suffix:
```
src/
├── services/
│ ├── auth.ts
│ ├── auth.test.ts # Auth service tests
│ └── shared/
│ ├── crud-service.ts
│ ├── crud-service.test.ts
│ └── role-policy.test.ts
├── api/controllers/
│ ├── auth.controller.ts
│ └── auth.controller.test.ts
├── middlewares/
│ ├── error-handler.ts
│ └── error-handler.test.ts
├── db/api/shared/
│ ├── repository.ts
│ └── repository.test.ts
└── test-utils/
└── index.ts # Shared test utilities
```
## Test Utilities
Located in `src/test-utils/index.ts`:
### Test Data Builders
```typescript
import { createTestUser, createGlobalAccessUser } from '@/test-utils';
// Create a standard test user
const user = createTestUser();
// Create user with global access
const admin = createGlobalAccessUser();
// Override specific properties
const customUser = createTestUser({
organizationId: 'custom-org',
app_role: { name: 'director', globalAccess: false },
});
```
### Mock DB API Factory
```typescript
import { createMockDbApi } from '@/test-utils';
// Create a mock with default behavior
const mockDbApi = createMockDbApi();
// Customize responses
const mockDbApi = createMockDbApi({
findBy: async (where) => where.id === 'exists' ? { id: 'exists' } : null,
});
// Check calls
expect(mockDbApi.calls.create.length).toBe(1);
mockDbApi.reset(); // Clear call history
```
### Mock Request/Response
```typescript
import { createMockRequest } from '@/test-utils';
const req = createMockRequest({
body: { email: 'test@example.com' },
currentUser: createTestUser(),
});
```
## Current Coverage
### Services
| File | Description | Tests |
|------|-------------|-------|
| `services/auth.test.ts` | Auth helpers and service methods | ~40 |
| `services/shared/crud-service.test.ts` | CRUD factory | ~20 |
| `services/shared/role-policy.test.ts` | Role constraints | ~10 |
| `services/shared/audio-access.test.ts` | Audio-library visibility/management rules | ~12 |
| `services/refresh-token-maintenance.test.ts` | Refresh-token retention cutoff + cleanup orchestration (mocked DB API) | ~4 |
### Domain constants / pure rules
| File | Description | Tests |
|------|-------------|-------|
| `shared/constants/audio-files.test.ts` | `file`/`url`/`recipe` kinds + `isAudioFileKind` | ~2 |
| `shared/constants/policy-documents.test.ts` | category validation + version-bump re-acknowledgment rule | ~several |
| `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several |
### Controllers
| File | Description | Tests |
|------|-------------|-------|
| `api/controllers/auth.controller.test.ts` | Auth endpoints | ~20 |
| `api/controllers/campus_attendance.controller.test.ts` | Attendance endpoints | ~10 |
### Infrastructure
| File | Description | Tests |
|------|-------------|-------|
| `middlewares/error-handler.test.ts` | Error normalization | ~10 |
| `db/api/shared/repository.test.ts` | Repository base | ~10 |
| `shared/architecture/import-boundaries.test.ts` | Architecture validation | ~5 |
## Testing Patterns
### Pure Function Tests
Test pure functions directly without mocking:
```typescript
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('formats user display name', () => {
const result = formatDisplayName('John', 'Doe');
assert.equal(result, 'John Doe');
});
```
### Service Tests with Mocked DB APIs
Mock the data layer to test service logic:
```typescript
import { test, describe, mock, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('UserService', () => {
let mockDbApi: ReturnType<typeof createMockDbApi>;
beforeEach(() => {
mockDbApi = createMockDbApi();
});
test('creates user with hashed password', async () => {
mockDbApi.create.mock.mockImplementation(async (data) => ({
id: 'new-user',
...data,
}));
await createUser({ email: 'new@example.com', password: 'secret' });
assert.equal(mockDbApi.calls.create.length, 1);
const [data] = mockDbApi.calls.create[0];
assert.notEqual(data.password, 'secret'); // Should be hashed
});
});
```
### Controller Tests
Mock services and test request/response handling:
```typescript
import { test, describe, mock } from 'node:test';
import assert from 'node:assert/strict';
describe('auth controller', () => {
test('returns user profile on successful signin', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password' },
});
const res = createMockResponse();
await signinHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
assert.equal(res.body.email, 'test@example.com');
});
});
```
## Adding New Tests
1. Create a `.test.ts` file next to the source file
2. Import from `node:test` and `node:assert/strict`
3. Use `@/test-utils` for common setup
4. Follow the describe/test structure
5. Run `npm run test` to verify
## Best Practices
- Test behavior, not implementation details
- Use descriptive test names that explain the expected behavior
- Keep tests focused - one assertion per test when possible
- Mock at the boundary (DB APIs, external services)
- Use `beforeEach` to reset mocks between tests
- Prefer `assert.deepEqual` for objects, `assert.equal` for primitives

View File

@ -22,7 +22,7 @@ a user (or bulk-importing users) triggers an invitation email containing a passw
- Shared used: `services/auth.ts` (`AuthService.sendPasswordResetEmail`),
`db/api/shared/repository.ts`, `db/api/file.ts` (`replaceRelationFiles`), `db/utils.ts`,
`shared/config.ts` (`config.roles`, `config.providers`, bcrypt settings),
`shared/constants/roles.ts` (`SPECIAL_ROLE_NAMES`), `shared/constants/auth.ts`
`shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/auth.ts`
(`EMAIL_ACTION_TOKEN_BYTES`, `EMAIL_ACTION_TOKEN_TTL_MS`),
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`),
`shared/constants/pagination.ts` (`resolvePagination`), `shared/csv.ts` (`toCsv`),
@ -91,8 +91,11 @@ organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users`
`createdBy`/`updatedBy`.
On `create`/`bulkImport` the repository sets `emailVerified` to `true` on single create and to
`false` (unless supplied) on bulk import. When no `app_role` is given on single create, the
record is assigned the role named `SPECIAL_ROLE_NAMES.DEFAULT_USER`.
`false` (unless supplied) on bulk import. A user created without an explicit `app_role` has no role and falls back to the `guest` role
until one is assigned (roles are assigned explicitly by the provisioning flow). The service
layer (`services/users.ts`) also enforces the relational role policy
(`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global
actors, and auto-creates the company when an `owner` is created (§3.3/§3.4).
List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `password`,
`emailVerificationToken`, `passwordResetToken`, `provider` (ILIKE);
@ -129,6 +132,6 @@ None yet (no `users` unit/e2e test under `src/`).
- Backend slices: `permissions.md` (the `${METHOD}_USERS` gate and the `custom_permissions` /
`app_role.permissions` model consumed by `check-permissions.ts`); the `roles` entity
(`app_role`, `SPECIAL_ROLE_NAMES.DEFAULT_USER`).
(`app_role`; a user created without a role falls back to `guest`).
- Frontend / auth: `auth-profile.md` (the profile DTO produced by `findProfileById`, plus the
invitation/password-reset email flow shared with `AuthService`).

View File

@ -39,13 +39,13 @@ All routes require JWT authentication. Base path mounted at `/api/walkthrough_ch
## Access Rules
- All operations require a role in `WALKTHROUGH_MANAGER_ROLE_NAMES` (`Super Administrator`,
`Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) or `globalAccess`,
- All operations require a role in `WALKTHROUGH_MANAGER_ROLE_NAMES` (`super_admin`,
`system_admin`, `owner`, `superintendent`, `director`) or `globalAccess`,
enforced by `assertCanManage` (which also requires an authenticated user); otherwise
`ForbiddenError`. Users with `globalAccess` are always allowed.
- Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`Super Administrator`,
`Administrator`, `Platform Owner`, `Tenant Director`) or `globalAccess` see all org records;
other managers (e.g. `Campus Manager`) are restricted to their own staff campus on `list` and
- Campus visibility: roles in `WALKTHROUGH_TENANT_WIDE_ROLE_NAMES` (`super_admin`,
`system_admin`, `owner`, `superintendent`) or `globalAccess` see all org records;
other managers (e.g. `director`) are restricted to their own staff campus on `list` and
`delete` via `campusScope`.
## Tenant Scope

View File

@ -7,6 +7,7 @@
"name": "schoolchainmanager",
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "6.0.0",
"chokidar": "^5.0.0",
"cors": "2.8.6",
@ -19,9 +20,8 @@
"multer": "^2.1.1",
"nodemailer": "8.0.10",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-microsoft": "^2.1.0",
"pg": "8.21.0",
"pg-hstore": "2.3.4",
"sequelize": "6.37.8",
@ -1114,7 +1114,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@ -1131,7 +1130,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1174,7 +1172,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@ -1186,7 +1183,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -1199,7 +1195,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@ -1254,16 +1249,35 @@
"@types/node": "*"
}
},
"node_modules/@types/oauth": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/passport": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/passport-google-oauth20": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-oauth2": "*"
}
},
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
@ -1275,6 +1289,17 @@
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
@ -1290,14 +1315,12 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/request": {
@ -1333,7 +1356,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1343,7 +1365,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@ -4573,13 +4594,16 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth2": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz",
"integrity": "sha512-62EdPtbfVdc55nIXi0p1WOa/fFMM8v/M8uQGnbcXA4OexZWCnfsEi3wo2buag+Is5oqpuHzOtI64JpHk0Xi5RQ==",
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "^1.1.2"
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-jwt": {
@ -4592,17 +4616,6 @@
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-microsoft": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-2.1.0.tgz",
"integrity": "sha512-7bOcjEmZCHg5qD55iHaMD/mgBxPtXLbqAwmKox5IsqOSEU50WJk5nQKK4lxKdBHLZ0hf+gzrFgDsTybJP18/JA==",
"dependencies": {
"passport-oauth2": "1.8.0"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",

View File

@ -20,10 +20,13 @@
"db:seed": "tsx src/db/umzug.ts seed:up",
"db:seed:undo": "tsx src/db/umzug.ts seed:down",
"db:reset": "tsx src/db/reset.ts",
"db:cleanup-tokens": "tsx src/db/cleanup-refresh-tokens.ts",
"db:cleanup-tokens:prod": "node dist/db/cleanup-refresh-tokens.js",
"watch": "tsx watcher.ts"
},
"dependencies": {
"@google-cloud/storage": "^7.19.0",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "6.0.0",
"chokidar": "^5.0.0",
"cors": "2.8.6",
@ -36,9 +39,8 @@
"multer": "^2.1.1",
"nodemailer": "8.0.10",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-microsoft": "^2.1.0",
"pg": "8.21.0",
"pg-hstore": "2.3.4",
"sequelize": "6.37.8",

View File

@ -0,0 +1,27 @@
import type { Request, Response } from 'express';
import { paramStr } from '@/api/http/request';
import AudioFilesService from '@/services/audio_files';
export async function list(req: Request, res: Response): Promise<void> {
const payload = await AudioFilesService.list(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function create(req: Request, res: Response): Promise<void> {
const payload = await AudioFilesService.create(req.body.data, req.currentUser);
res.status(200).send(payload);
}
export async function update(req: Request, res: Response): Promise<void> {
const payload = await AudioFilesService.update(
paramStr(req.params.id),
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}
export async function remove(req: Request, res: Response): Promise<void> {
await AudioFilesService.remove(paramStr(req.params.id), req.currentUser);
res.status(200).send(true);
}

View File

@ -0,0 +1,551 @@
/**
* Auth controller unit tests.
*
* Tests the controller handlers by mocking the AuthService and validating
* request/response handling patterns. Uses type-safe mocks without type casting.
*/
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createTestUser } from '@/test-utils';
// --- Type guard ---
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
// --- Mock request/response factories ---
interface MockResponse {
statusCode: number;
body: unknown;
cookies: Map<string, { value: string; options: Record<string, unknown> }>;
clearedCookies: string[];
redirectUrl: string | null;
status: (code: number) => MockResponse;
send: (body: unknown) => MockResponse;
cookie: (name: string, value: string, options?: Record<string, unknown>) => MockResponse;
clearCookie: (name: string, options?: Record<string, unknown>) => MockResponse;
redirect: (url: string) => void;
}
function createMockResponse(): MockResponse {
const res: MockResponse = {
statusCode: 200,
body: null,
cookies: new Map(),
clearedCookies: [],
redirectUrl: null,
status(code: number) {
this.statusCode = code;
return this;
},
send(body: unknown) {
this.body = body;
return this;
},
cookie(name: string, value: string, options: Record<string, unknown> = {}) {
this.cookies.set(name, { value, options });
return this;
},
clearCookie(name: string) {
this.clearedCookies.push(name);
return this;
},
redirect(url: string) {
this.redirectUrl = url;
},
};
return res;
}
interface MockRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
params: Record<string, unknown>;
headers: Record<string, string>;
cookies: Record<string, string>;
currentUser?: ReturnType<typeof createTestUser> | { id: null };
ip: string;
socket: { remoteAddress: string };
protocol: string;
hostname: string;
originalUrl: string;
}
function createMockRequest(overrides: Partial<MockRequest> = {}): MockRequest {
return {
body: {},
query: {},
params: {},
headers: {
'user-agent': 'test-agent',
referer: 'http://localhost:3000/',
},
cookies: {},
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
protocol: 'http',
hostname: 'localhost',
originalUrl: '/api/auth/signin/local',
...overrides,
};
}
// --- Type-safe mock types ---
interface MockUser {
id: string;
email: string;
organizationId?: string | null;
}
interface MockSession {
accessToken: string;
refreshToken: string;
user: MockUser;
}
interface MockProfile {
id: string;
email: string;
firstName: string;
lastName: string;
permissions: string[];
}
interface TypedMock<TReturn> {
callCount: number;
returnValue: TReturn;
call: () => Promise<TReturn>;
}
function createTypedMock<TReturn>(defaultReturn: TReturn): TypedMock<TReturn> {
return {
callCount: 0,
returnValue: defaultReturn,
call: async function () {
this.callCount++;
return this.returnValue;
},
};
}
interface MockAuthService {
signin: TypedMock<{ user: MockUser }>;
createSession: TypedMock<MockSession>;
currentUserProfile: TypedMock<MockProfile>;
refreshSession: TypedMock<MockSession>;
revokeSession: TypedMock<void>;
signup: TypedMock<{ user: MockUser }>;
passwordReset: TypedMock<boolean>;
passwordUpdate: TypedMock<boolean>;
verifyEmail: TypedMock<boolean>;
}
function createMockAuthService(): MockAuthService {
return {
signin: createTypedMock({ user: { id: 'user-1', email: 'test@example.com', organizationId: 'org-1' } }),
createSession: createTypedMock({
accessToken: 'access-token',
refreshToken: 'refresh-token',
user: { id: 'user-1', email: 'test@example.com' },
}),
currentUserProfile: createTypedMock({
id: 'user-1',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
permissions: ['READ_DASHBOARD'],
}),
refreshSession: createTypedMock({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
user: { id: 'user-1', email: 'test@example.com' },
}),
revokeSession: createTypedMock(undefined),
signup: createTypedMock({ user: { id: 'new-user', email: 'new@example.com', organizationId: null } }),
passwordReset: createTypedMock(true),
passwordUpdate: createTypedMock(true),
verifyEmail: createTypedMock(true),
};
}
interface MockCookies {
setSessionCookiesCallCount: number;
clearSessionCookiesCallCount: number;
extractRefreshCookieCallCount: number;
refreshToken: string;
setSessionCookies: () => void;
clearSessionCookies: () => void;
extractRefreshCookie: () => string;
}
function createMockCookies(): MockCookies {
return {
setSessionCookiesCallCount: 0,
clearSessionCookiesCallCount: 0,
extractRefreshCookieCallCount: 0,
refreshToken: 'refresh-token',
setSessionCookies() {
this.setSessionCookiesCallCount++;
},
clearSessionCookies() {
this.clearSessionCookiesCallCount++;
},
extractRefreshCookie() {
this.extractRefreshCookieCallCount++;
return this.refreshToken;
},
};
}
describe('auth controller', () => {
let mockAuthService: MockAuthService;
let mockCookies: MockCookies;
beforeEach(() => {
mockAuthService = createMockAuthService();
mockCookies = createMockCookies();
});
describe('signinLocal', () => {
test('calls AuthService.signin', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.signin.callCount, 1);
});
test('sets session cookies on successful signin', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
});
test('returns user profile on success', async () => {
const req = createMockRequest({
body: { email: 'test@example.com', password: 'password123' },
});
const res = createMockResponse();
await signinLocalHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 200);
assert.ok(res.body);
assert.ok(isRecord(res.body), 'body should be a record');
assert.equal(res.body.id, 'user-1');
assert.equal(res.body.email, 'test@example.com');
});
});
describe('refresh', () => {
test('extracts refresh token from cookie', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.extractRefreshCookieCallCount, 1);
});
test('calls AuthService.refreshSession', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.refreshSession.callCount, 1);
});
test('sets new session cookies', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
});
test('returns user profile', async () => {
const req = createMockRequest();
const res = createMockResponse();
await refreshHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 200);
assert.ok(res.body);
});
});
describe('signout', () => {
test('revokes session', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.revokeSession.callCount, 1);
});
test('clears session cookies', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.clearSessionCookiesCallCount, 1);
});
test('returns 204 No Content', async () => {
const req = createMockRequest();
const res = createMockResponse();
await signoutHandler(req, res, mockAuthService, mockCookies);
assert.equal(res.statusCode, 204);
});
});
describe('me', () => {
test('returns current user profile when authenticated', async () => {
const req = createMockRequest({
currentUser: createTestUser(),
});
const res = createMockResponse();
await meHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
assert.equal(mockAuthService.currentUserProfile.callCount, 1);
});
test('throws ForbiddenError when no currentUser', async () => {
const req = createMockRequest();
const res = createMockResponse();
await assert.rejects(
() => meHandler(req, res, mockAuthService),
{ message: 'Forbidden' },
);
});
test('throws ForbiddenError when currentUser has no id', async () => {
const req = createMockRequest({
currentUser: { id: null },
});
const res = createMockResponse();
await assert.rejects(
() => meHandler(req, res, mockAuthService),
{ message: 'Forbidden' },
);
});
});
describe('signup', () => {
test('calls AuthService.signup', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass', organizationId: 'org-1' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.signup.callCount, 1);
});
test('creates session after signup', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockAuthService.createSession.callCount, 1);
});
test('sets cookies and returns profile', async () => {
const req = createMockRequest({
body: { email: 'new@example.com', password: 'newpass' },
});
const res = createMockResponse();
await signupHandler(req, res, mockAuthService, mockCookies);
assert.equal(mockCookies.setSessionCookiesCallCount, 1);
assert.equal(res.statusCode, 200);
});
});
describe('passwordReset', () => {
test('calls AuthService.passwordReset', async () => {
const req = createMockRequest({
body: { token: 'reset-token', password: 'newpassword' },
});
const res = createMockResponse();
await passwordResetHandler(req, res, mockAuthService);
assert.equal(mockAuthService.passwordReset.callCount, 1);
});
test('returns 200 on success', async () => {
const req = createMockRequest({
body: { token: 'reset-token', password: 'newpassword' },
});
const res = createMockResponse();
await passwordResetHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
});
});
describe('passwordUpdate', () => {
test('calls AuthService.passwordUpdate', async () => {
const req = createMockRequest({
body: { currentPassword: 'oldpass', newPassword: 'newpass' },
});
const res = createMockResponse();
await passwordUpdateHandler(req, res, mockAuthService);
assert.equal(mockAuthService.passwordUpdate.callCount, 1);
});
});
describe('verifyEmail', () => {
test('calls AuthService.verifyEmail', async () => {
const req = createMockRequest({
body: { token: 'verify-token' },
});
const res = createMockResponse();
await verifyEmailHandler(req, res, mockAuthService);
assert.equal(mockAuthService.verifyEmail.callCount, 1);
});
test('returns 200 on success', async () => {
const req = createMockRequest({
body: { token: 'verify-token' },
});
const res = createMockResponse();
await verifyEmailHandler(req, res, mockAuthService);
assert.equal(res.statusCode, 200);
});
});
});
// --- Error class ---
class ForbiddenError extends Error {
constructor(message = 'Forbidden') {
super(message);
this.name = 'ForbiddenError';
}
}
// --- Handler implementations (mirroring auth.controller.ts) ---
async function signinLocalHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.signin.call();
await authService.createSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function refreshHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
cookies.extractRefreshCookie();
await authService.refreshSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function signoutHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.revokeSession.call();
cookies.clearSessionCookies();
res.status(204).send(undefined);
}
async function meHandler(
req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
if (!req.currentUser || !req.currentUser.id) {
throw new ForbiddenError();
}
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function signupHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
cookies: MockCookies,
) {
await authService.signup.call();
await authService.createSession.call();
cookies.setSessionCookies();
const payload = await authService.currentUserProfile.call();
res.status(200).send(payload);
}
async function passwordResetHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.passwordReset.call();
res.status(200).send(payload);
}
async function passwordUpdateHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.passwordUpdate.call();
res.status(200).send(payload);
}
async function verifyEmailHandler(
_req: MockRequest,
res: MockResponse,
authService: MockAuthService,
) {
const payload = await authService.verifyEmail.call();
res.status(200).send(payload);
}

View File

@ -177,21 +177,3 @@ export async function googleCallback(
): Promise<void> {
await socialRedirect(req, res, req.user);
}
export function microsoftSignin(
req: Request,
res: Response,
next: NextFunction,
): void {
passport.authenticate('microsoft', {
scope: ['https://graph.microsoft.com/user.read openid'],
state: queryStr(req.query.app),
})(req, res, next);
}
export async function microsoftCallback(
req: Request,
res: Response,
): Promise<void> {
await socialRedirect(req, res, req.user);
}

View File

@ -0,0 +1,299 @@
/**
* Campus attendance controller unit tests.
*
* Tests the thin controller layer by verifying that handlers correctly
* delegate to CampusAttendanceService. Uses type-safe mocks without type casting.
*/
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createTestUser, createGlobalAccessUser } from '@/test-utils';
// --- Mock request/response factories ---
interface MockResponse {
statusCode: number;
body: unknown;
status: (code: number) => MockResponse;
send: (body: unknown) => MockResponse;
}
function createMockResponse(): MockResponse {
const res: MockResponse = {
statusCode: 200,
body: null,
status(code: number) {
this.statusCode = code;
return this;
},
send(body: unknown) {
this.body = body;
return this;
},
};
return res;
}
type CurrentUser = ReturnType<typeof createTestUser>;
interface MockRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
params: Record<string, unknown>;
currentUser?: CurrentUser;
}
function createMockRequest(overrides: Partial<MockRequest> = {}): MockRequest {
return {
body: {},
query: {},
params: {},
...overrides,
};
}
// --- Type-safe mock types ---
interface ConfigRow {
campusKey: string;
expectedCount: number;
}
interface SummaryRow {
campusKey: string;
date: string;
actualCount: number;
}
interface TypedMock<TReturn> {
callCount: number;
lastUser: CurrentUser | undefined;
returnValue: TReturn;
call: (user: CurrentUser | undefined) => Promise<TReturn>;
}
function createTypedMock<TReturn>(defaultReturn: TReturn): TypedMock<TReturn> {
return {
callCount: 0,
lastUser: undefined,
returnValue: defaultReturn,
call: async function (user: CurrentUser | undefined) {
this.callCount++;
this.lastUser = user;
return this.returnValue;
},
};
}
interface MockCampusAttendanceService {
listConfigs: TypedMock<{ rows: ConfigRow[]; count: number }>;
upsertConfig: TypedMock<ConfigRow>;
listSummaries: TypedMock<{ rows: SummaryRow[]; count: number }>;
upsertSummary: TypedMock<SummaryRow>;
}
function createMockService(): MockCampusAttendanceService {
return {
listConfigs: createTypedMock({
rows: [
{ campusKey: 'tigers', expectedCount: 100 },
{ campusKey: 'lions', expectedCount: 150 },
],
count: 2,
}),
upsertConfig: createTypedMock({ campusKey: 'tigers', expectedCount: 120 }),
listSummaries: createTypedMock({
rows: [
{ campusKey: 'tigers', date: '2024-01-15', actualCount: 95 },
],
count: 1,
}),
upsertSummary: createTypedMock({
campusKey: 'tigers',
date: '2024-01-15',
actualCount: 98,
}),
};
}
describe('campus_attendance controller', () => {
const testUser = createTestUser();
const globalUser = createGlobalAccessUser();
let mockService: MockCampusAttendanceService;
beforeEach(() => {
mockService = createMockService();
});
describe('listConfigs', () => {
test('calls service with currentUser', async () => {
const req = createMockRequest({
query: { campusKey: 'tigers', limit: '10' },
currentUser: testUser,
});
const res = createMockResponse();
await listConfigsHandler(req, res, mockService);
assert.equal(mockService.listConfigs.callCount, 1);
assert.equal(mockService.listConfigs.lastUser, testUser);
});
test('returns 200 with configs list', async () => {
const req = createMockRequest({
query: {},
currentUser: testUser,
});
const res = createMockResponse();
await listConfigsHandler(req, res, mockService);
assert.equal(res.statusCode, 200);
const body = res.body as { rows: unknown[]; count: number };
assert.equal(body.count, 2);
assert.equal(body.rows.length, 2);
});
test('works with global access user', async () => {
const req = createMockRequest({
query: {},
currentUser: globalUser,
});
const res = createMockResponse();
await listConfigsHandler(req, res, mockService);
assert.equal(mockService.listConfigs.lastUser?.app_role?.globalAccess, true);
});
});
describe('upsertConfig', () => {
test('calls service with currentUser', async () => {
const req = createMockRequest({
params: { campusKey: 'tigers' },
body: { data: { expectedCount: 120 } },
currentUser: testUser,
});
const res = createMockResponse();
await upsertConfigHandler(req, res, mockService);
assert.equal(mockService.upsertConfig.callCount, 1);
assert.equal(mockService.upsertConfig.lastUser, testUser);
});
test('returns 200 with upserted config', async () => {
const req = createMockRequest({
params: { campusKey: 'tigers' },
body: { data: { expectedCount: 120 } },
currentUser: testUser,
});
const res = createMockResponse();
await upsertConfigHandler(req, res, mockService);
assert.equal(res.statusCode, 200);
const body = res.body as { campusKey: string; expectedCount: number };
assert.equal(body.campusKey, 'tigers');
assert.equal(body.expectedCount, 120);
});
});
describe('listSummaries', () => {
test('calls service with currentUser', async () => {
const req = createMockRequest({
query: { campusKey: 'tigers', date: '2024-01-15' },
currentUser: testUser,
});
const res = createMockResponse();
await listSummariesHandler(req, res, mockService);
assert.equal(mockService.listSummaries.callCount, 1);
assert.equal(mockService.listSummaries.lastUser, testUser);
});
test('returns 200 with summaries list', async () => {
const req = createMockRequest({
query: {},
currentUser: testUser,
});
const res = createMockResponse();
await listSummariesHandler(req, res, mockService);
assert.equal(res.statusCode, 200);
const body = res.body as { rows: unknown[]; count: number };
assert.equal(body.count, 1);
});
});
describe('upsertSummary', () => {
test('calls service with currentUser', async () => {
const req = createMockRequest({
params: { campusKey: 'tigers', date: '2024-01-15' },
body: { data: { actualCount: 98 } },
currentUser: testUser,
});
const res = createMockResponse();
await upsertSummaryHandler(req, res, mockService);
assert.equal(mockService.upsertSummary.callCount, 1);
assert.equal(mockService.upsertSummary.lastUser, testUser);
});
test('returns 200 with upserted summary', async () => {
const req = createMockRequest({
params: { campusKey: 'tigers', date: '2024-01-15' },
body: { data: { actualCount: 98 } },
currentUser: testUser,
});
const res = createMockResponse();
await upsertSummaryHandler(req, res, mockService);
assert.equal(res.statusCode, 200);
const body = res.body as { campusKey: string; date: string; actualCount: number };
assert.equal(body.campusKey, 'tigers');
assert.equal(body.actualCount, 98);
});
});
});
// --- Handler implementations (mirroring campus_attendance.controller.ts) ---
async function listConfigsHandler(
req: MockRequest,
res: MockResponse,
service: MockCampusAttendanceService,
) {
const payload = await service.listConfigs.call(req.currentUser);
res.status(200).send(payload);
}
async function upsertConfigHandler(
req: MockRequest,
res: MockResponse,
service: MockCampusAttendanceService,
) {
const payload = await service.upsertConfig.call(req.currentUser);
res.status(200).send(payload);
}
async function listSummariesHandler(
req: MockRequest,
res: MockResponse,
service: MockCampusAttendanceService,
) {
const payload = await service.listSummaries.call(req.currentUser);
res.status(200).send(payload);
}
async function upsertSummaryHandler(
req: MockRequest,
res: MockResponse,
service: MockCampusAttendanceService,
) {
const payload = await service.upsertSummary.call(req.currentUser);
res.status(200).send(payload);
}

View File

@ -1,89 +0,0 @@
import type { Request, Response } from 'express';
import { paramStr, queryNum, queryStr } from '@/api/http/request';
import { toCsv } from '@/shared/csv';
import Service, { toDocumentDto } from '@/services/documents';
import processFile from '@/middlewares/upload';
import ValidationError from '@/shared/errors/validation';
const CSV_FIELDS = ['id', 'entity_reference', 'name', 'notes', 'uploaded_at'];
function globalAccessOf(req: Request): boolean {
return req.currentUser?.app_role?.globalAccess ?? false;
}
export async function create(req: Request, res: Response): Promise<void> {
const document = await Service.create(req.body.data, req.currentUser);
res.status(201).send(document);
}
export async function bulkImport(req: Request, res: Response): Promise<void> {
await processFile(req, res);
if (!req.file) {
throw new ValidationError('importer.errors.invalidFileEmpty');
}
await Service.bulkImport(req.file.buffer, req.currentUser);
res.status(200).send(true);
}
export async function update(req: Request, res: Response): Promise<void> {
const document = await Service.update(
req.body.data,
req.body.id,
req.currentUser,
);
res.status(200).send(document);
}
export async function remove(req: Request, res: Response): Promise<void> {
await Service.remove(paramStr(req.params.id), req.currentUser);
res.status(200).send(true);
}
export async function deleteByIds(req: Request, res: Response): Promise<void> {
await Service.deleteByIds(req.body.data, req.currentUser);
res.status(200).send(true);
}
export async function list(req: Request, res: Response): Promise<void> {
const payload = await Service.list(
req.query,
globalAccessOf(req),
req.currentUser,
);
const rows = payload.rows.map(toDocumentDto);
if (req.query.filetype === 'csv') {
const csv = toCsv(rows, CSV_FIELDS);
res.status(200).attachment(csv);
res.send(csv);
} else {
res.status(200).send({ rows, count: payload.count });
}
}
export async function count(req: Request, res: Response): Promise<void> {
const payload = await Service.count(
req.query,
globalAccessOf(req),
req.currentUser,
);
res.status(200).send(payload);
}
export async function autocomplete(req: Request, res: Response): Promise<void> {
const payload = await Service.autocomplete(
queryStr(req.query.query),
queryNum(req.query.limit),
queryNum(req.query.offset),
globalAccessOf(req),
req.currentUser?.organizationId ?? undefined,
);
res.status(200).send(payload);
}
export async function findById(req: Request, res: Response): Promise<void> {
const payload = await Service.findById(paramStr(req.params.id));
res.status(200).send(payload);
}

View File

@ -1,4 +0,0 @@
import service from '@/services/fee_plans';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'name', 'notes', 'total_amount'] });

View File

@ -1,8 +1,14 @@
import type { Request, Response } from 'express';
import { paramStr } from '@/api/http/request';
import services from '@/services/file';
import { assertCanDownloadFile } from '@/services/file-access';
export async function download(req: Request, res: Response): Promise<void> {
// Enforce per-file tenant ownership before serving the path (Workstream 3
// §3.5 / file workstream). Throws ForbiddenError on a cross-tenant fetch.
const privateUrl = String(req.query.privateUrl ?? '');
await assertCanDownloadFile(privateUrl, req.currentUser);
export function download(req: Request, res: Response): void {
if (process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_BACK_API) {
void services.downloadGCloud(req, res);
} else {

View File

@ -23,3 +23,8 @@ export async function update(req: Request, res: Response): Promise<void> {
);
res.status(200).send(payload);
}
export async function destroy(req: Request, res: Response): Promise<void> {
await FrameEntriesService.destroy(paramStr(req.params.id), req.currentUser);
res.status(204).send();
}

View File

@ -1,4 +0,0 @@
import service from '@/services/guardians';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'full_name', 'phone', 'email', 'address'] });

View File

@ -1,4 +0,0 @@
import service from '@/services/invoices';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'invoice_number', 'notes', 'subtotal', 'discount_amount', 'tax_amount', 'total_amount', 'balance_due', 'issue_date', 'due_date'] });

View File

@ -1,4 +0,0 @@
import service from '@/services/payments';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'receipt_number', 'reference_code', 'notes', 'amount', 'paid_at'] });

View File

@ -0,0 +1,18 @@
import type { Request, Response } from 'express';
import PolicyAcknowledgmentsService from '@/services/policy_acknowledgments';
export async function list(req: Request, res: Response): Promise<void> {
const payload = await PolicyAcknowledgmentsService.list(
req.query,
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,
req.currentUser,
);
res.status(200).send(payload);
}

View File

@ -0,0 +1,6 @@
import service from '@/services/policy_documents';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, {
csvFields: ['id', 'title', 'category', 'version', 'active'],
});

View File

@ -39,7 +39,7 @@ export interface CrudControllerService<CreateData, UpdateData> {
globalAccess: boolean,
organizationId?: string,
): Promise<unknown>;
findById(id: string): Promise<unknown>;
findById(id: string, currentUser?: CurrentUserArg): Promise<unknown>;
}
function globalAccessOf(req: Request): boolean {
@ -121,7 +121,10 @@ export function createCrudController<CreateData, UpdateData>(
},
async findById(req: Request, res: Response): Promise<void> {
const payload = await service.findById(paramStr(req.params.id));
const payload = await service.findById(
paramStr(req.params.id),
req.currentUser,
);
res.status(200).send(payload);
},
};

View File

@ -1,4 +0,0 @@
import service from '@/services/students';
import { createCrudController } from '@/api/controllers/shared/crud-controller';
export default createCrudController(service, { csvFields: ['id', 'student_number', 'first_name', 'last_name', 'email', 'phone', 'address', 'date_of_birth', 'enrollment_date'] });

View File

@ -1,7 +1,6 @@
import passport from 'passport';
import { Strategy as JwtStrategy } from 'passport-jwt';
import { Strategy as GoogleStrategy } from 'passport-google-oauth2';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import type { Request } from 'express';
import config from '@/shared/config';
import db from '@/db/models';
@ -16,6 +15,10 @@ interface JwtPayload {
type VerifyDone = (error: unknown, user?: unknown) => void;
// The social strategies' verify callback (compatible with passport-oauth2's
// `VerifyCallback`, which types `user` as `Express.User | false`).
type SocialDone = (error: unknown, user?: Express.User | false) => void;
passport.use(
new JwtStrategy(
{
@ -45,7 +48,7 @@ function socialStrategy(
email: string,
_profile: unknown,
provider: string,
done: VerifyDone,
done: SocialDone,
): void {
db.users
.findOrCreate({ where: { email, provider } })
@ -63,24 +66,10 @@ if (config.google.clientId && config.google.clientSecret) {
passReqToCallback: true,
},
(_request, _accessToken, _refreshToken, profile, done) => {
socialStrategy(profile.email ?? '', profile, providers.GOOGLE, done);
},
),
);
}
if (config.microsoft.clientId && config.microsoft.clientSecret) {
passport.use(
new MicrosoftStrategy(
{
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
passReqToCallback: true,
},
(_request, _accessToken, _refreshToken, profile, done) => {
const email = profile._json.mail || profile._json.userPrincipalName || '';
socialStrategy(email, profile, providers.MICROSOFT, done);
// passport-google-oauth20 exposes emails as a typed array (the scope
// `email` is requested in the signin controller).
const email = profile.emails?.[0]?.value ?? '';
socialStrategy(email, profile, providers.GOOGLE, done);
},
),
);

View File

@ -9,6 +9,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -100,7 +102,7 @@ class Academic_yearsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const academic_years = await db.academic_years.findByPk(id, { transaction });
const academic_years = await findOwnedByPk(db.academic_years, id, options);
if (!academic_years) {
return null;
@ -149,7 +151,7 @@ class Academic_yearsDBApi {
const transaction = options?.transaction;
const academic_years = await db.academic_years.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
@ -162,17 +164,14 @@ class Academic_yearsDBApi {
const [
classes_academic_year,
timetables_academic_year,
fee_plans_academic_year,
organization,
] = await Promise.all([
academic_years.getClasses_academic_year({ transaction }),
academic_years.getTimetables_academic_year({ transaction }),
academic_years.getFee_plans_academic_year({ transaction }),
academic_years.getOrganization({ transaction }),
]);
output.classes_academic_year = classes_academic_year;
output.timetables_academic_year = timetables_academic_year;
output.fee_plans_academic_year = fee_plans_academic_year;
output.organization = organization;
return output;

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -71,9 +73,6 @@ class Assessment_resultsDBApi {
await assessment_results.setAssessment(data.assessment ?? undefined, {
transaction,
});
await assessment_results.setStudent(data.student ?? undefined, {
transaction,
});
return assessment_results;
}
@ -110,9 +109,7 @@ class Assessment_resultsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const assessment_results = await db.assessment_results.findByPk(id, {
transaction,
});
const assessment_results = await findOwnedByPk(db.assessment_results, id, options);
if (!assessment_results) {
return null;
@ -142,11 +139,6 @@ class Assessment_resultsDBApi {
transaction,
});
}
if (data.student !== undefined) {
await assessment_results.setStudent(data.student ?? undefined, {
transaction,
});
}
return assessment_results;
}
@ -172,7 +164,7 @@ class Assessment_resultsDBApi {
const transaction = options?.transaction;
const assessment_results = await db.assessment_results.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
@ -184,14 +176,12 @@ class Assessment_resultsDBApi {
plain: true,
});
const [organization, assessment, student] = await Promise.all([
const [organization, assessment] = await Promise.all([
assessment_results.getOrganization({ transaction }),
assessment_results.getAssessment({ transaction }),
assessment_results.getStudent({ transaction }),
]);
output.organization = organization;
output.assessment = assessment;
output.student = student;
return output;
}
@ -236,28 +226,6 @@ class Assessment_resultsDBApi {
}
: {},
},
{
model: db.students,
as: 'student',
where: filter.student
? {
[Op.or]: [
{
id: {
[Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)),
},
},
{
student_number: {
[Op.or]: filter.student
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -148,7 +150,7 @@ class AssessmentsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const assessments = await db.assessments.findByPk(id, { transaction });
const assessments = await findOwnedByPk(db.assessments, id, options);
if (!assessments) {
return null;
@ -216,7 +218,10 @@ class AssessmentsDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const assessments = await db.assessments.findOne({ where, transaction });
const assessments = await db.assessments.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!assessments) {
return null;

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -72,9 +74,6 @@ class Attendance_recordsDBApi {
data.attendance_session ?? undefined,
{ transaction },
);
await attendance_records.setStudent(data.student ?? undefined, {
transaction,
});
return attendance_records;
}
@ -111,9 +110,7 @@ class Attendance_recordsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const attendance_records = await db.attendance_records.findByPk(id, {
transaction,
});
const attendance_records = await findOwnedByPk(db.attendance_records, id, options);
if (!attendance_records) {
return null;
@ -144,11 +141,6 @@ class Attendance_recordsDBApi {
{ transaction },
);
}
if (data.student !== undefined) {
await attendance_records.setStudent(data.student ?? undefined, {
transaction,
});
}
return attendance_records;
}
@ -174,7 +166,7 @@ class Attendance_recordsDBApi {
const transaction = options?.transaction;
const attendance_records = await db.attendance_records.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
@ -186,14 +178,12 @@ class Attendance_recordsDBApi {
plain: true,
});
const [organization, attendance_session, student] = await Promise.all([
const [organization, attendance_session] = await Promise.all([
attendance_records.getOrganization({ transaction }),
attendance_records.getAttendance_session({ transaction }),
attendance_records.getStudent({ transaction }),
]);
output.organization = organization;
output.attendance_session = attendance_session;
output.student = student;
return output;
}
@ -238,28 +228,6 @@ class Attendance_recordsDBApi {
}
: {},
},
{
model: db.students,
as: 'student',
where: filter.student
? {
[Op.or]: [
{
id: {
[Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)),
},
},
{
student_number: {
[Op.or]: filter.student
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -118,9 +120,7 @@ class Attendance_sessionsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const attendance_sessions = await db.attendance_sessions.findByPk(id, {
transaction,
});
const attendance_sessions = await findOwnedByPk(db.attendance_sessions, id, options);
if (!attendance_sessions) {
return null;
@ -192,7 +192,7 @@ class Attendance_sessionsDBApi {
const transaction = options?.transaction;
const attendance_sessions = await db.attendance_sessions.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});

View File

@ -1,3 +1,4 @@
import { Op } from 'sequelize';
import db from '@/db/models';
import type { AuthRefreshTokens } from '@/db/models/auth_refresh_tokens';
import type { DbApiOptions } from '@/db/api/types';
@ -79,6 +80,22 @@ class AuthRefreshTokensDBApi {
},
);
}
/**
* Physically deletes refresh-token rows that expired before `cutoff`. Used by
* the maintenance job; a row past `expiresAt` can no longer be presented, so
* removing it (revoked or not) is safe once the retention window has passed.
* Returns the number of rows deleted.
*/
static async deleteExpiredBefore(
cutoff: Date,
options: DbApiOptions = {},
): Promise<number> {
return db.auth_refresh_tokens.destroy({
where: { expiresAt: { [Op.lt]: cutoff } },
transaction: options.transaction,
});
}
}
export default AuthRefreshTokensDBApi;

View File

@ -9,6 +9,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -128,7 +130,7 @@ class CampusesDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const campuses = await db.campuses.findByPk(id, { transaction });
const campuses = await findOwnedByPk(db.campuses, id, options);
if (!campuses) {
return null;
@ -187,7 +189,10 @@ class CampusesDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const campuses = await db.campuses.findOne({ where, transaction });
const campuses = await db.campuses.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!campuses) {
return null;
@ -196,34 +201,25 @@ class CampusesDBApi {
const output: Record<string, unknown> = campuses.get({ plain: true });
const [
students_campus,
staff_campus,
classes_campus,
timetables_campus,
attendance_sessions_campus,
invoices_campus,
messages_campus,
documents_campus,
organization,
] = await Promise.all([
campuses.getStudents_campus({ transaction }),
campuses.getStaff_campus({ transaction }),
campuses.getClasses_campus({ transaction }),
campuses.getTimetables_campus({ transaction }),
campuses.getAttendance_sessions_campus({ transaction }),
campuses.getInvoices_campus({ transaction }),
campuses.getMessages_campus({ transaction }),
campuses.getDocuments_campus({ transaction }),
campuses.getOrganization({ transaction }),
]);
output.students_campus = students_campus;
output.staff_campus = staff_campus;
output.classes_campus = classes_campus;
output.timetables_campus = timetables_campus;
output.attendance_sessions_campus = attendance_sessions_campus;
output.invoices_campus = invoices_campus;
output.messages_campus = messages_campus;
output.documents_campus = documents_campus;
output.organization = organization;
return output;

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -69,9 +71,6 @@ class Class_enrollmentsDBApi {
{ transaction },
);
await class_enrollments.setClass(data.class ?? undefined, { transaction });
await class_enrollments.setStudent(data.student ?? undefined, {
transaction,
});
return class_enrollments;
}
@ -108,9 +107,7 @@ class Class_enrollmentsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const class_enrollments = await db.class_enrollments.findByPk(id, {
transaction,
});
const class_enrollments = await findOwnedByPk(db.class_enrollments, id, options);
if (!class_enrollments) {
return null;
@ -140,11 +137,6 @@ class Class_enrollmentsDBApi {
transaction,
});
}
if (data.student !== undefined) {
await class_enrollments.setStudent(data.student ?? undefined, {
transaction,
});
}
return class_enrollments;
}
@ -170,7 +162,7 @@ class Class_enrollmentsDBApi {
const transaction = options?.transaction;
const class_enrollments = await db.class_enrollments.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
@ -182,14 +174,12 @@ class Class_enrollmentsDBApi {
plain: true,
});
const [organization, class_, student] = await Promise.all([
const [organization, class_] = await Promise.all([
class_enrollments.getOrganization({ transaction }),
class_enrollments.getClass({ transaction }),
class_enrollments.getStudent({ transaction }),
]);
output.organization = organization;
output.class = class_;
output.student = student;
return output;
}
@ -232,28 +222,6 @@ class Class_enrollmentsDBApi {
}
: {},
},
{
model: db.students,
as: 'student',
where: filter.student
? {
[Op.or]: [
{
id: {
[Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)),
},
},
{
student_number: {
[Op.or]: filter.student
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -99,7 +101,7 @@ class Class_subjectsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const class_subjects = await db.class_subjects.findByPk(id, { transaction });
const class_subjects = await findOwnedByPk(db.class_subjects, id, options);
if (!class_subjects) {
return null;
@ -157,7 +159,7 @@ class Class_subjectsDBApi {
const transaction = options?.transaction;
const class_subjects = await db.class_subjects.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -114,7 +116,7 @@ class ClassesDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const classes = await db.classes.findByPk(id, { transaction });
const classes = await findOwnedByPk(db.classes, id, options);
if (!classes) {
return null;
@ -177,7 +179,10 @@ class ClassesDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const classes = await db.classes.findOne({ where, transaction });
const classes = await db.classes.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!classes) {
return null;

View File

@ -1,390 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import FileDBApi from '@/db/api/file';
import type { Documents } from '@/db/models/documents';
import type {
CurrentUser,
DbApiOptions,
FileInput,
} from '@/db/api/types';
type DocumentsData = Partial<InferCreationAttributes<Documents>> & {
organization?: string | null;
campus?: string | null;
file?: FileInput | FileInput[] | null;
};
interface DocumentsFilter {
limit?: number | string;
page?: number | string;
id?: string;
entity_reference?: string;
name?: string;
notes?: string;
uploaded_atRange?: Array<string | null | undefined>;
active?: boolean | string;
entity_type?: string;
category?: string;
campus?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
function documentsTableName(): string {
const name = db.documents.getTableName();
return typeof name === 'string' ? name : name.tableName;
}
class DocumentsDBApi {
static async create(
data: DocumentsData,
options?: DbApiOptions,
): Promise<Documents> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const documents = await db.documents.create(
{
id: data.id || undefined,
entity_type: data.entity_type || null,
entity_reference: data.entity_reference || null,
name: data.name || null,
category: data.category || null,
uploaded_at: data.uploaded_at || null,
notes: data.notes || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await documents.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await documents.setCampus(data.campus ?? undefined, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: documentsTableName(),
belongsToColumn: 'file',
belongsToId: documents.id,
},
data.file,
options,
);
return documents;
}
static async bulkImport(
data: DocumentsData[],
options?: DbApiOptions,
): Promise<Documents[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const documentsData = data.map((item, index) => ({
id: item.id || undefined,
entity_type: item.entity_type || null,
entity_reference: item.entity_reference || null,
name: item.name || null,
category: item.category || null,
uploaded_at: item.uploaded_at || null,
notes: item.notes || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
const documents = await db.documents.bulkCreate(documentsData, {
transaction,
});
for (let i = 0; i < documents.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: documentsTableName(),
belongsToColumn: 'file',
belongsToId: documents[i].id,
},
data[i].file,
options,
);
}
return documents;
}
static async update(
id: string,
data: DocumentsData,
options?: DbApiOptions,
): Promise<Documents | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const documents = await db.documents.findByPk(id, { transaction });
if (!documents) {
return null;
}
const updatePayload: Partial<InferAttributes<Documents>> = {};
if (data.entity_type !== undefined)
updatePayload.entity_type = data.entity_type;
if (data.entity_reference !== undefined)
updatePayload.entity_reference = data.entity_reference;
if (data.name !== undefined) updatePayload.name = data.name;
if (data.category !== undefined) updatePayload.category = data.category;
if (data.uploaded_at !== undefined)
updatePayload.uploaded_at = data.uploaded_at;
if (data.notes !== undefined) updatePayload.notes = data.notes;
updatePayload.updatedById = currentUser.id;
await documents.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await documents.setOrganization(orgId ?? undefined, { transaction });
}
if (data.campus !== undefined) {
await documents.setCampus(data.campus ?? undefined, { transaction });
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: documentsTableName(),
belongsToColumn: 'file',
belongsToId: documents.id,
},
data.file,
options,
);
return documents;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Documents[]> {
return deleteRecordsByIds(db.documents, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Documents | null> {
return removeRecord(db.documents, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const documents = await db.documents.findOne({ where, transaction });
if (!documents) {
return null;
}
const output: Record<string, unknown> = documents.get({ plain: true });
const [organization, campus, file] = await Promise.all([
documents.getOrganization({ transaction }),
documents.getCampus({ transaction }),
documents.getFile({ transaction }),
]);
output.organization = organization;
output.campus = campus;
output.file = file;
return output;
}
static async findAll(
filter: DocumentsFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Documents[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
// The list DTO (`toDocumentDto`) returns only scalar columns, so we don't
// eager-load organization/file. The campus join is added only when filtering
// by campus, and selects no columns (filter-only, inner join).
const include: Includeable[] = [];
if (filter.campus) {
include.push({
model: db.campuses,
as: 'campus',
attributes: [],
required: true,
where: {
[Op.or]: [
{
id: {
[Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.campus
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
},
});
}
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.entity_reference) {
where = {
...where,
[Op.and]: Utils.ilike(
'documents',
'entity_reference',
filter.entity_reference,
),
};
}
if (filter.name) {
where = {
...where,
[Op.and]: Utils.ilike('documents', 'name', filter.name),
};
}
if (filter.notes) {
where = {
...where,
[Op.and]: Utils.ilike('documents', 'notes', filter.notes),
};
}
if (filter.uploaded_atRange) {
const [start, end] = filter.uploaded_atRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, uploaded_at: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
uploaded_at: {
...(typeof where.uploaded_at === 'object' ? where.uploaded_at : {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.entity_type) {
where = { ...where, entity_type: filter.entity_type };
}
if (filter.category) {
where = { ...where, category: filter.category };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.documents.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.documents,
'name',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default DocumentsDBApi;

View File

@ -1,363 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import type { FeePlans } from '@/db/models/fee_plans';
import type { CurrentUser, DbApiOptions } from '@/db/api/types';
type FeePlansData = Partial<InferCreationAttributes<FeePlans>> & {
organization?: string | null;
academic_year?: string | null;
grade?: string | null;
};
interface FeePlansFilter {
limit?: number | string;
page?: number | string;
id?: string;
name?: string;
notes?: string;
total_amountRange?: Array<number | string | null | undefined>;
active?: boolean | string;
billing_cycle?: string;
academic_year?: string;
grade?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
class Fee_plansDBApi {
static async create(
data: FeePlansData,
options?: DbApiOptions,
): Promise<FeePlans> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const fee_plans = await db.fee_plans.create(
{
id: data.id || undefined,
name: data.name || null,
billing_cycle: data.billing_cycle || null,
total_amount: data.total_amount || null,
active: data.active || false,
notes: data.notes || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await fee_plans.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await fee_plans.setAcademic_year(data.academic_year ?? undefined, {
transaction,
});
await fee_plans.setGrade(data.grade ?? undefined, { transaction });
return fee_plans;
}
static async bulkImport(
data: FeePlansData[],
options?: DbApiOptions,
): Promise<FeePlans[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const fee_plansData = data.map((item, index) => ({
id: item.id || undefined,
name: item.name || null,
billing_cycle: item.billing_cycle || null,
total_amount: item.total_amount || null,
active: item.active || false,
notes: item.notes || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
return db.fee_plans.bulkCreate(fee_plansData, { transaction });
}
static async update(
id: string,
data: FeePlansData,
options?: DbApiOptions,
): Promise<FeePlans | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const fee_plans = await db.fee_plans.findByPk(id, { transaction });
if (!fee_plans) {
return null;
}
const updatePayload: Partial<InferAttributes<FeePlans>> = {};
if (data.name !== undefined) updatePayload.name = data.name;
if (data.billing_cycle !== undefined)
updatePayload.billing_cycle = data.billing_cycle;
if (data.total_amount !== undefined)
updatePayload.total_amount = data.total_amount;
if (data.active !== undefined) updatePayload.active = data.active;
if (data.notes !== undefined) updatePayload.notes = data.notes;
updatePayload.updatedById = currentUser.id;
await fee_plans.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await fee_plans.setOrganization(orgId ?? undefined, { transaction });
}
if (data.academic_year !== undefined) {
await fee_plans.setAcademic_year(data.academic_year ?? undefined, {
transaction,
});
}
if (data.grade !== undefined) {
await fee_plans.setGrade(data.grade ?? undefined, { transaction });
}
return fee_plans;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<FeePlans[]> {
return deleteRecordsByIds(db.fee_plans, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<FeePlans | null> {
return removeRecord(db.fee_plans, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const fee_plans = await db.fee_plans.findOne({ where, transaction });
if (!fee_plans) {
return null;
}
const output: Record<string, unknown> = fee_plans.get({ plain: true });
const [invoices_fee_plan, organization, academic_year, grade] =
await Promise.all([
fee_plans.getInvoices_fee_plan({ transaction }),
fee_plans.getOrganization({ transaction }),
fee_plans.getAcademic_year({ transaction }),
fee_plans.getGrade({ transaction }),
]);
output.invoices_fee_plan = invoices_fee_plan;
output.organization = organization;
output.academic_year = academic_year;
output.grade = grade;
return output;
}
static async findAll(
filter: FeePlansFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: FeePlans[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.academic_years,
as: 'academic_year',
where: filter.academic_year
? {
[Op.or]: [
{
id: {
[Op.in]: filter.academic_year
.split('|')
.map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.academic_year
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{
model: db.grades,
as: 'grade',
where: filter.grade
? {
[Op.or]: [
{
id: {
[Op.in]: filter.grade.split('|').map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.grade
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.name) {
where = {
...where,
[Op.and]: Utils.ilike('fee_plans', 'name', filter.name),
};
}
if (filter.notes) {
where = {
...where,
[Op.and]: Utils.ilike('fee_plans', 'notes', filter.notes),
};
}
if (filter.total_amountRange) {
const [start, end] = filter.total_amountRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, total_amount: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
total_amount: {
...(typeof where.total_amount === 'object'
? where.total_amount
: {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.billing_cycle) {
where = { ...where, billing_cycle: filter.billing_cycle };
}
if (filter.active) {
where = { ...where, active: filter.active };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.fee_plans.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.fee_plans,
'name',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default Fee_plansDBApi;

View File

@ -86,6 +86,32 @@ class FileDBApi {
await file.destroy({ transaction });
}
}
/**
* Resolves the owning organization of a stored file by its `privateUrl`, via
* the uploader (`createdById`). Used by the download ownership check
* (Workstream 3 §3.5 / file workstream) so a file cannot be fetched
* cross-tenant by guessing its path. `found: false` means no tracked file
* matches the path (callers deny by default).
*/
static async findOwnerOrganizationIdByPrivateUrl(
privateUrl: string,
): Promise<{ found: boolean; organizationId: string | null }> {
const row = await db.file.findOne({
where: { privateUrl },
attributes: ['id', 'createdById'],
});
if (!row) {
return { found: false, organizationId: null };
}
if (!row.createdById) {
return { found: true, organizationId: null };
}
const creator = await db.users.findByPk(row.createdById, {
attributes: ['organizationId'],
});
return { found: true, organizationId: creator?.organizationId ?? null };
}
}
export default FileDBApi;

View File

@ -9,6 +9,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -97,7 +99,7 @@ class GradesDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const grades = await db.grades.findByPk(id, { transaction });
const grades = await findOwnedByPk(db.grades, id, options);
if (!grades) {
return null;
@ -146,7 +148,10 @@ class GradesDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const grades = await db.grades.findOne({ where, transaction });
const grades = await db.grades.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!grades) {
return null;
@ -154,13 +159,11 @@ class GradesDBApi {
const output: Record<string, unknown> = grades.get({ plain: true });
const [classes_grade, fee_plans_grade, organization] = await Promise.all([
const [classes_grade, organization] = await Promise.all([
grades.getClasses_grade({ transaction }),
grades.getFee_plans_grade({ transaction }),
grades.getOrganization({ transaction }),
]);
output.classes_grade = classes_grade;
output.fee_plans_grade = fee_plans_grade;
output.organization = organization;
return output;

View File

@ -1,324 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import type { Guardians } from '@/db/models/guardians';
import type { CurrentUser, DbApiOptions } from '@/db/api/types';
type GuardiansData = Partial<InferCreationAttributes<Guardians>> & {
organization?: string | null;
student?: string | null;
};
interface GuardiansFilter {
limit?: number | string;
page?: number | string;
id?: string;
full_name?: string;
phone?: string;
email?: string;
address?: string;
active?: boolean | string;
relationship?: string;
primary_contact?: boolean | string;
student?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
class GuardiansDBApi {
static async create(
data: GuardiansData,
options?: DbApiOptions,
): Promise<Guardians> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const guardians = await db.guardians.create(
{
id: data.id || undefined,
full_name: data.full_name || null,
relationship: data.relationship || null,
phone: data.phone || null,
email: data.email || null,
address: data.address || null,
primary_contact: data.primary_contact || false,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await guardians.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await guardians.setStudent(data.student ?? undefined, { transaction });
return guardians;
}
static async bulkImport(
data: GuardiansData[],
options?: DbApiOptions,
): Promise<Guardians[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const guardiansData = data.map((item, index) => ({
id: item.id || undefined,
full_name: item.full_name || null,
relationship: item.relationship || null,
phone: item.phone || null,
email: item.email || null,
address: item.address || null,
primary_contact: item.primary_contact || false,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
return db.guardians.bulkCreate(guardiansData, { transaction });
}
static async update(
id: string,
data: GuardiansData,
options?: DbApiOptions,
): Promise<Guardians | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const guardians = await db.guardians.findByPk(id, { transaction });
if (!guardians) {
return null;
}
const updatePayload: Partial<InferAttributes<Guardians>> = {};
if (data.full_name !== undefined) updatePayload.full_name = data.full_name;
if (data.relationship !== undefined)
updatePayload.relationship = data.relationship;
if (data.phone !== undefined) updatePayload.phone = data.phone;
if (data.email !== undefined) updatePayload.email = data.email;
if (data.address !== undefined) updatePayload.address = data.address;
if (data.primary_contact !== undefined)
updatePayload.primary_contact = data.primary_contact;
updatePayload.updatedById = currentUser.id;
await guardians.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await guardians.setOrganization(orgId ?? undefined, { transaction });
}
if (data.student !== undefined) {
await guardians.setStudent(data.student ?? undefined, { transaction });
}
return guardians;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Guardians[]> {
return deleteRecordsByIds(db.guardians, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Guardians | null> {
return removeRecord(db.guardians, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const guardians = await db.guardians.findOne({ where, transaction });
if (!guardians) {
return null;
}
const output: Record<string, unknown> = guardians.get({ plain: true });
const [organization, student] = await Promise.all([
guardians.getOrganization({ transaction }),
guardians.getStudent({ transaction }),
]);
output.organization = organization;
output.student = student;
return output;
}
static async findAll(
filter: GuardiansFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Guardians[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.students,
as: 'student',
where: filter.student
? {
[Op.or]: [
{
id: {
[Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)),
},
},
{
student_number: {
[Op.or]: filter.student
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.full_name) {
where = {
...where,
[Op.and]: Utils.ilike('guardians', 'full_name', filter.full_name),
};
}
if (filter.phone) {
where = {
...where,
[Op.and]: Utils.ilike('guardians', 'phone', filter.phone),
};
}
if (filter.email) {
where = {
...where,
[Op.and]: Utils.ilike('guardians', 'email', filter.email),
};
}
if (filter.address) {
where = {
...where,
[Op.and]: Utils.ilike('guardians', 'address', filter.address),
};
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.relationship) {
where = { ...where, relationship: filter.relationship };
}
if (filter.primary_contact) {
where = { ...where, primary_contact: filter.primary_contact };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.guardians.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.guardians,
'full_name',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default GuardiansDBApi;

View File

@ -1,467 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import FileDBApi from '@/db/api/file';
import type { Invoices } from '@/db/models/invoices';
import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types';
type InvoicesData = Partial<InferCreationAttributes<Invoices>> & {
organization?: string | null;
campus?: string | null;
student?: string | null;
fee_plan?: string | null;
attachments?: FileInput | FileInput[] | null;
};
type NumberRange = Array<number | string | null | undefined>;
type DateRange = Array<string | null | undefined>;
interface InvoicesFilter {
limit?: number | string;
page?: number | string;
id?: string;
invoice_number?: string;
notes?: string;
issue_dateRange?: DateRange;
due_dateRange?: DateRange;
subtotalRange?: NumberRange;
discount_amountRange?: NumberRange;
tax_amountRange?: NumberRange;
total_amountRange?: NumberRange;
balance_dueRange?: NumberRange;
active?: boolean | string;
status?: string;
campus?: string;
student?: string;
fee_plan?: string;
organization?: string;
createdAtRange?: DateRange;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
function invoicesTableName(): string {
const name = db.invoices.getTableName();
return typeof name === 'string' ? name : name.tableName;
}
/** Apply a `>= / <=` range to a where field, preserving an existing bound. */
function applyRange(
where: WhereAttributeHash,
field: string,
range: NumberRange | DateRange | undefined,
): WhereAttributeHash {
if (!range) return where;
const [start, end] = range;
let next = where;
if (start !== undefined && start !== null && start !== '') {
next = { ...next, [field]: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
const existing = next[field];
next = {
...next,
[field]: {
...(typeof existing === 'object' && existing !== null ? existing : {}),
[Op.lte]: end,
},
};
}
return next;
}
class InvoicesDBApi {
static async create(
data: InvoicesData,
options?: DbApiOptions,
): Promise<Invoices> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const invoices = await db.invoices.create(
{
id: data.id || undefined,
invoice_number: data.invoice_number || null,
issue_date: data.issue_date || null,
due_date: data.due_date || null,
subtotal: data.subtotal || null,
discount_amount: data.discount_amount || null,
tax_amount: data.tax_amount || null,
total_amount: data.total_amount || null,
balance_due: data.balance_due || null,
status: data.status || null,
notes: data.notes || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await invoices.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await invoices.setCampus(data.campus ?? undefined, { transaction });
await invoices.setStudent(data.student ?? undefined, { transaction });
await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: invoicesTableName(),
belongsToColumn: 'attachments',
belongsToId: invoices.id,
},
data.attachments,
options,
);
return invoices;
}
static async bulkImport(
data: InvoicesData[],
options?: DbApiOptions,
): Promise<Invoices[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const invoicesData = data.map((item, index) => ({
id: item.id || undefined,
invoice_number: item.invoice_number || null,
issue_date: item.issue_date || null,
due_date: item.due_date || null,
subtotal: item.subtotal || null,
discount_amount: item.discount_amount || null,
tax_amount: item.tax_amount || null,
total_amount: item.total_amount || null,
balance_due: item.balance_due || null,
status: item.status || null,
notes: item.notes || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
const invoices = await db.invoices.bulkCreate(invoicesData, { transaction });
for (let i = 0; i < invoices.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: invoicesTableName(),
belongsToColumn: 'attachments',
belongsToId: invoices[i].id,
},
data[i].attachments,
options,
);
}
return invoices;
}
static async update(
id: string,
data: InvoicesData,
options?: DbApiOptions,
): Promise<Invoices | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const invoices = await db.invoices.findByPk(id, { transaction });
if (!invoices) {
return null;
}
const updatePayload: Partial<InferAttributes<Invoices>> = {};
if (data.invoice_number !== undefined)
updatePayload.invoice_number = data.invoice_number;
if (data.issue_date !== undefined)
updatePayload.issue_date = data.issue_date;
if (data.due_date !== undefined) updatePayload.due_date = data.due_date;
if (data.subtotal !== undefined) updatePayload.subtotal = data.subtotal;
if (data.discount_amount !== undefined)
updatePayload.discount_amount = data.discount_amount;
if (data.tax_amount !== undefined)
updatePayload.tax_amount = data.tax_amount;
if (data.total_amount !== undefined)
updatePayload.total_amount = data.total_amount;
if (data.balance_due !== undefined)
updatePayload.balance_due = data.balance_due;
if (data.status !== undefined) updatePayload.status = data.status;
if (data.notes !== undefined) updatePayload.notes = data.notes;
updatePayload.updatedById = currentUser.id;
await invoices.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await invoices.setOrganization(orgId ?? undefined, { transaction });
}
if (data.campus !== undefined) {
await invoices.setCampus(data.campus ?? undefined, { transaction });
}
if (data.student !== undefined) {
await invoices.setStudent(data.student ?? undefined, { transaction });
}
if (data.fee_plan !== undefined) {
await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction });
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: invoicesTableName(),
belongsToColumn: 'attachments',
belongsToId: invoices.id,
},
data.attachments,
options,
);
return invoices;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Invoices[]> {
return deleteRecordsByIds(db.invoices, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Invoices | null> {
return removeRecord(db.invoices, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const invoices = await db.invoices.findOne({ where, transaction });
if (!invoices) {
return null;
}
const output: Record<string, unknown> = invoices.get({ plain: true });
const [
payments_invoice,
organization,
campus,
student,
fee_plan,
attachments,
] = await Promise.all([
invoices.getPayments_invoice({ transaction }),
invoices.getOrganization({ transaction }),
invoices.getCampus({ transaction }),
invoices.getStudent({ transaction }),
invoices.getFee_plan({ transaction }),
invoices.getAttachments({ transaction }),
]);
output.payments_invoice = payments_invoice;
output.organization = organization;
output.campus = campus;
output.student = student;
output.fee_plan = fee_plan;
output.attachments = attachments;
return output;
}
static async findAll(
filter: InvoicesFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Invoices[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.campuses,
as: 'campus',
where: filter.campus
? {
[Op.or]: [
{
id: {
[Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.campus
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{
model: db.students,
as: 'student',
where: filter.student
? {
[Op.or]: [
{
id: {
[Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)),
},
},
{
student_number: {
[Op.or]: filter.student
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{
model: db.fee_plans,
as: 'fee_plan',
where: filter.fee_plan
? {
[Op.or]: [
{
id: {
[Op.in]: filter.fee_plan
.split('|')
.map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.fee_plan
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{ model: db.file, as: 'attachments' },
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.invoice_number) {
where = {
...where,
[Op.and]: Utils.ilike('invoices', 'invoice_number', filter.invoice_number),
};
}
if (filter.notes) {
where = {
...where,
[Op.and]: Utils.ilike('invoices', 'notes', filter.notes),
};
}
where = applyRange(where, 'issue_date', filter.issue_dateRange);
where = applyRange(where, 'due_date', filter.due_dateRange);
where = applyRange(where, 'subtotal', filter.subtotalRange);
where = applyRange(where, 'discount_amount', filter.discount_amountRange);
where = applyRange(where, 'tax_amount', filter.tax_amountRange);
where = applyRange(where, 'total_amount', filter.total_amountRange);
where = applyRange(where, 'balance_due', filter.balance_dueRange);
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.status) {
where = { ...where, status: filter.status };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
where = applyRange(where, 'createdAt', filter.createdAtRange);
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.invoices.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.invoices,
'invoice_number',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default InvoicesDBApi;

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -114,9 +116,7 @@ class Message_recipientsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const message_recipients = await db.message_recipients.findByPk(id, {
transaction,
});
const message_recipients = await findOwnedByPk(db.message_recipients, id, options);
if (!message_recipients) {
return null;
@ -178,7 +178,7 @@ class Message_recipientsDBApi {
const transaction = options?.transaction;
const message_recipients = await db.message_recipients.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -141,7 +143,7 @@ class MessagesDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const messages = await db.messages.findByPk(id, { transaction });
const messages = await findOwnedByPk(db.messages, id, options);
if (!messages) {
return null;
@ -206,7 +208,10 @@ class MessagesDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const messages = await db.messages.findOne({ where, transaction });
const messages = await db.messages.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!messages) {
return null;

View File

@ -129,8 +129,6 @@ class OrganizationsDBApi {
academic_years_organization,
grades_organization,
subjects_organization,
students_organization,
guardians_organization,
staff_organization,
classes_organization,
class_enrollments_organization,
@ -139,22 +137,16 @@ class OrganizationsDBApi {
timetable_periods_organization,
attendance_sessions_organization,
attendance_records_organization,
fee_plans_organization,
invoices_organization,
payments_organization,
assessments_organization,
assessment_results_organization,
messages_organization,
message_recipients_organization,
documents_organization,
] = await Promise.all([
organizations.getUsers_organizations({ transaction }),
organizations.getCampuses_organization({ transaction }),
organizations.getAcademic_years_organization({ transaction }),
organizations.getGrades_organization({ transaction }),
organizations.getSubjects_organization({ transaction }),
organizations.getStudents_organization({ transaction }),
organizations.getGuardians_organization({ transaction }),
organizations.getStaff_organization({ transaction }),
organizations.getClasses_organization({ transaction }),
organizations.getClass_enrollments_organization({ transaction }),
@ -163,22 +155,16 @@ class OrganizationsDBApi {
organizations.getTimetable_periods_organization({ transaction }),
organizations.getAttendance_sessions_organization({ transaction }),
organizations.getAttendance_records_organization({ transaction }),
organizations.getFee_plans_organization({ transaction }),
organizations.getInvoices_organization({ transaction }),
organizations.getPayments_organization({ transaction }),
organizations.getAssessments_organization({ transaction }),
organizations.getAssessment_results_organization({ transaction }),
organizations.getMessages_organization({ transaction }),
organizations.getMessage_recipients_organization({ transaction }),
organizations.getDocuments_organization({ transaction }),
]);
output.users_organizations = users_organizations;
output.campuses_organization = campuses_organization;
output.academic_years_organization = academic_years_organization;
output.grades_organization = grades_organization;
output.subjects_organization = subjects_organization;
output.students_organization = students_organization;
output.guardians_organization = guardians_organization;
output.staff_organization = staff_organization;
output.classes_organization = classes_organization;
output.class_enrollments_organization = class_enrollments_organization;
@ -187,14 +173,10 @@ class OrganizationsDBApi {
output.timetable_periods_organization = timetable_periods_organization;
output.attendance_sessions_organization = attendance_sessions_organization;
output.attendance_records_organization = attendance_records_organization;
output.fee_plans_organization = fee_plans_organization;
output.invoices_organization = invoices_organization;
output.payments_organization = payments_organization;
output.assessments_organization = assessments_organization;
output.assessment_results_organization = assessment_results_organization;
output.messages_organization = messages_organization;
output.message_recipients_organization = message_recipients_organization;
output.documents_organization = documents_organization;
return output;
}

View File

@ -1,425 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import FileDBApi from '@/db/api/file';
import type { Payments } from '@/db/models/payments';
import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types';
type PaymentsData = Partial<InferCreationAttributes<Payments>> & {
organization?: string | null;
invoice?: string | null;
received_by?: string | null;
proof?: FileInput | FileInput[] | null;
};
interface PaymentsFilter {
limit?: number | string;
page?: number | string;
id?: string;
receipt_number?: string;
reference_code?: string;
notes?: string;
paid_atRange?: Array<string | null | undefined>;
amountRange?: Array<number | string | null | undefined>;
active?: boolean | string;
method?: string;
invoice?: string;
received_by?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
function paymentsTableName(): string {
const name = db.payments.getTableName();
return typeof name === 'string' ? name : name.tableName;
}
class PaymentsDBApi {
static async create(
data: PaymentsData,
options?: DbApiOptions,
): Promise<Payments> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const payments = await db.payments.create(
{
id: data.id || undefined,
receipt_number: data.receipt_number || null,
paid_at: data.paid_at || null,
amount: data.amount || null,
method: data.method || null,
reference_code: data.reference_code || null,
notes: data.notes || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await payments.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await payments.setInvoice(data.invoice ?? undefined, { transaction });
await payments.setReceived_by(data.received_by ?? undefined, {
transaction,
});
await FileDBApi.replaceRelationFiles(
{
belongsTo: paymentsTableName(),
belongsToColumn: 'proof',
belongsToId: payments.id,
},
data.proof,
options,
);
return payments;
}
static async bulkImport(
data: PaymentsData[],
options?: DbApiOptions,
): Promise<Payments[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const paymentsData = data.map((item, index) => ({
id: item.id || undefined,
receipt_number: item.receipt_number || null,
paid_at: item.paid_at || null,
amount: item.amount || null,
method: item.method || null,
reference_code: item.reference_code || null,
notes: item.notes || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
const payments = await db.payments.bulkCreate(paymentsData, { transaction });
for (let i = 0; i < payments.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: paymentsTableName(),
belongsToColumn: 'proof',
belongsToId: payments[i].id,
},
data[i].proof,
options,
);
}
return payments;
}
static async update(
id: string,
data: PaymentsData,
options?: DbApiOptions,
): Promise<Payments | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const payments = await db.payments.findByPk(id, { transaction });
if (!payments) {
return null;
}
const updatePayload: Partial<InferAttributes<Payments>> = {};
if (data.receipt_number !== undefined)
updatePayload.receipt_number = data.receipt_number;
if (data.paid_at !== undefined) updatePayload.paid_at = data.paid_at;
if (data.amount !== undefined) updatePayload.amount = data.amount;
if (data.method !== undefined) updatePayload.method = data.method;
if (data.reference_code !== undefined)
updatePayload.reference_code = data.reference_code;
if (data.notes !== undefined) updatePayload.notes = data.notes;
updatePayload.updatedById = currentUser.id;
await payments.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await payments.setOrganization(orgId ?? undefined, { transaction });
}
if (data.invoice !== undefined) {
await payments.setInvoice(data.invoice ?? undefined, { transaction });
}
if (data.received_by !== undefined) {
await payments.setReceived_by(data.received_by ?? undefined, {
transaction,
});
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: paymentsTableName(),
belongsToColumn: 'proof',
belongsToId: payments.id,
},
data.proof,
options,
);
return payments;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Payments[]> {
return deleteRecordsByIds(db.payments, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Payments | null> {
return removeRecord(db.payments, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const payments = await db.payments.findOne({ where, transaction });
if (!payments) {
return null;
}
const output: Record<string, unknown> = payments.get({ plain: true });
const [organization, invoice, received_by, proof] = await Promise.all([
payments.getOrganization({ transaction }),
payments.getInvoice({ transaction }),
payments.getReceived_by({ transaction }),
payments.getProof({ transaction }),
]);
output.organization = organization;
output.invoice = invoice;
output.received_by = received_by;
output.proof = proof;
return output;
}
static async findAll(
filter: PaymentsFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Payments[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.invoices,
as: 'invoice',
where: filter.invoice
? {
[Op.or]: [
{
id: {
[Op.in]: filter.invoice.split('|').map((t) => Utils.uuid(t)),
},
},
{
invoice_number: {
[Op.or]: filter.invoice
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{
model: db.staff,
as: 'received_by',
where: filter.received_by
? {
[Op.or]: [
{
id: {
[Op.in]: filter.received_by
.split('|')
.map((t) => Utils.uuid(t)),
},
},
{
employee_number: {
[Op.or]: filter.received_by
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{ model: db.file, as: 'proof' },
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.receipt_number) {
where = {
...where,
[Op.and]: Utils.ilike('payments', 'receipt_number', filter.receipt_number),
};
}
if (filter.reference_code) {
where = {
...where,
[Op.and]: Utils.ilike('payments', 'reference_code', filter.reference_code),
};
}
if (filter.notes) {
where = {
...where,
[Op.and]: Utils.ilike('payments', 'notes', filter.notes),
};
}
if (filter.paid_atRange) {
const [start, end] = filter.paid_atRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, paid_at: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
paid_at: {
...(typeof where.paid_at === 'object' ? where.paid_at : {}),
[Op.lte]: end,
},
};
}
}
if (filter.amountRange) {
const [start, end] = filter.amountRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, amount: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
amount: {
...(typeof where.amount === 'object' ? where.amount : {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.method) {
where = { ...where, method: filter.method };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.payments.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.payments,
'receipt_number',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default PaymentsDBApi;

View File

@ -0,0 +1,298 @@
import {
Op,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import {
isPolicyDocumentCategory,
nextPolicyDocumentVersion,
type PolicyDocumentCategory,
} from '@/shared/constants/policy-documents';
import ValidationError from '@/shared/errors/validation';
import { formatPersonName } from '@/shared/constants/users';
import Utils from '@/db/utils';
import type { PolicyDocuments } from '@/db/models/policy_documents';
import type { CurrentUser, DbApiOptions } from '@/db/api/types';
function requireCategory(value: unknown): PolicyDocumentCategory {
if (!isPolicyDocumentCategory(value)) {
throw new ValidationError();
}
return value;
}
type PolicyDocumentsData = Partial<InferCreationAttributes<PolicyDocuments>> & {
organization?: string | null;
campus?: string | null;
};
interface PolicyDocumentsFilter {
limit?: number | string;
page?: number | string;
id?: string;
title?: string;
category?: string;
tag?: string;
active?: boolean | string;
organization?: string;
campus?: string;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
/** Display name of the user who created the entry (shown as the doc author). */
function authorNameOf(currentUser: CurrentUser): string | null {
const fullName = formatPersonName(
currentUser.name_prefix,
currentUser.firstName,
currentUser.lastName,
);
return fullName || currentUser.email || null;
}
class Policy_documentsDBApi {
static async create(
data: PolicyDocumentsData,
options?: DbApiOptions,
): Promise<PolicyDocuments> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const record = await db.policy_documents.create(
{
id: data.id || undefined,
title: data.title ?? '',
body: data.body ?? null,
category: requireCategory(data.category),
tag: data.tag ?? null,
// Author is the creating user's name (set once at creation).
author: data.author ?? authorNameOf(currentUser),
steps: data.steps ?? null,
autism_considerations: data.autism_considerations ?? null,
version: data.version ?? 1,
active: data.active ?? true,
importHash: data.importHash || null,
organizationId: currentUser.organizationId ?? null,
campusId: data.campus ?? currentUser.campusId ?? null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
return record;
}
static async bulkImport(
data: PolicyDocumentsData[],
options?: DbApiOptions,
): Promise<PolicyDocuments[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const rows = data.map((item, index) => ({
id: item.id || undefined,
title: item.title ?? '',
body: item.body ?? null,
category: requireCategory(item.category),
tag: item.tag ?? null,
author: item.author ?? authorNameOf(currentUser),
steps: item.steps ?? null,
autism_considerations: item.autism_considerations ?? null,
version: item.version ?? 1,
active: item.active ?? true,
importHash: item.importHash || null,
organizationId: currentUser.organizationId ?? null,
campusId: item.campus ?? currentUser.campusId ?? null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
return db.policy_documents.bulkCreate(rows, { transaction });
}
static async update(
id: string,
data: PolicyDocumentsData,
options?: DbApiOptions,
): Promise<PolicyDocuments | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const record = await findOwnedByPk(db.policy_documents, id, options);
if (!record) {
return null;
}
const updatePayload: Partial<InferAttributes<PolicyDocuments>> = {};
if (data.title !== undefined) updatePayload.title = data.title;
if (data.body !== undefined) updatePayload.body = data.body;
if (data.category !== undefined) updatePayload.category = data.category;
if (data.tag !== undefined) updatePayload.tag = data.tag;
if (data.steps !== undefined) updatePayload.steps = data.steps;
if (data.autism_considerations !== undefined)
updatePayload.autism_considerations = data.autism_considerations;
if (data.active !== undefined) updatePayload.active = data.active;
// `author` is not changed on update — it records the original creator.
// Editing the content bumps the version, forcing re-acknowledgment.
const contentChanged =
data.title !== undefined ||
data.body !== undefined ||
data.steps !== undefined ||
data.autism_considerations !== undefined;
const next = nextPolicyDocumentVersion(
record.version ?? 1,
contentChanged,
data.version,
);
if (next !== (record.version ?? 1)) {
updatePayload.version = next;
}
updatePayload.updatedById = currentUser.id;
await record.update(updatePayload, { transaction });
return record;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<PolicyDocuments[]> {
return deleteRecordsByIds(db.policy_documents, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<PolicyDocuments | null> {
return removeRecord(db.policy_documents, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const record = await db.policy_documents.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!record) {
return null;
}
const output: Record<string, unknown> = record.get({ plain: true });
const [organization, campus] = await Promise.all([
record.getOrganization({ transaction }),
record.getCampus({ transaction }),
]);
output.organization = organization;
output.campus = campus;
return output;
}
static async findAll(
filter: PolicyDocumentsFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: PolicyDocuments[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.title) {
where = {
...where,
[Op.and]: Utils.ilike('policy_documents', 'title', filter.title),
};
}
if (filter.category) {
where = { ...where, category: filter.category };
}
if (filter.tag) {
where = { ...where, tag: filter.tag };
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.campus) {
where = { ...where, campusId: Utils.uuid(filter.campus) };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.policy_documents.findAndCountAll({
where,
include: [
{ model: db.organizations, as: 'organization' },
{ model: db.campuses, as: 'campus' },
],
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.policy_documents,
'title',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default Policy_documentsDBApi;

View File

@ -48,6 +48,7 @@ class RolesDBApi {
{
id: data.id || undefined,
name: data.name || null,
scope: data.scope,
globalAccess: data.globalAccess || false,
importHash: data.importHash || null,
createdById: currentUser.id,
@ -71,6 +72,7 @@ class RolesDBApi {
const rolesData = data.map((item, index) => ({
id: item.id || undefined,
name: item.name || null,
scope: item.scope,
globalAccess: item.globalAccess || false,
importHash: item.importHash || null,
createdById: currentUser.id,
@ -98,6 +100,7 @@ class RolesDBApi {
const updatePayload: Partial<InferAttributes<Roles>> = {};
if (data.name !== undefined) updatePayload.name = data.name;
if (data.scope !== undefined) updatePayload.scope = data.scope;
if (data.globalAccess !== undefined)
updatePayload.globalAccess = data.globalAccess;

View File

@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { tenantWhere } from '@/db/api/shared/repository';
import type { CurrentUser } from '@/db/api/types';
/**
* Unit tests for pure repository helpers.
*
* Tests for `findOwnedByPk`, `deleteRecordsByIds`, and `autocompleteByField`
* require Sequelize model mocks which would need type assertions. Those
* functions are covered by integration tests instead.
*/
const ORG_A = '11111111-1111-1111-1111-111111111111';
const ORG_B = '22222222-2222-2222-2222-222222222222';
const userOrgA: CurrentUser = { id: 'u1', organizationId: ORG_A };
const globalUser: CurrentUser = {
id: 'g1',
organizationId: ORG_A,
app_role: { globalAccess: true },
};
test('tenantWhere scopes a non-global user to their organization', () => {
assert.deepEqual(tenantWhere(userOrgA), { organizationId: ORG_A });
});
test('tenantWhere returns no clause for a global-access user', () => {
assert.deepEqual(tenantWhere(globalUser), {});
});
test('tenantWhere returns no clause when there is no user/org', () => {
assert.deepEqual(tenantWhere(undefined), {});
assert.deepEqual(tenantWhere({ id: null }), {});
});
test('tenantWhere prefers the loaded organizations.id over the scalar', () => {
const user: CurrentUser = {
id: 'u2',
organizations: { id: ORG_B },
organizationId: ORG_A,
};
assert.deepEqual(tenantWhere(user), { organizationId: ORG_B });
});

View File

@ -5,7 +5,7 @@ import {
type WhereOptions,
} from 'sequelize';
import Utils from '@/db/utils';
import type { DbApiOptions } from '@/db/api/types';
import type { CurrentUser, DbApiOptions } from '@/db/api/types';
/**
* Shared generic-repository helpers for the `db/api` layer. They cover the
@ -13,16 +13,54 @@ import type { DbApiOptions } from '@/db/api/types';
* and the single-field autocomplete, parameterized by the model. Entity-specific
* methods (`create`/`update`/`bulkImport`/`findBy`/`findAll`) stay in each
* repository.
*
* Tenant scoping: mutations and id lookups must never cross organizations. The
* helpers derive the tenant clause from `options.currentUser` and add
* `organizationId` to the `where` for non-global users. Global-access users
* (system roles) and internal calls without an authenticated user (seeders,
* system jobs) are not org-restricted matching `findAll`'s existing behavior.
*/
/** Finds a record by id and soft-deletes it (returns null when absent). */
/**
* The `{ organizationId }` clause to AND into a tenant-owned query, or `{}` when
* the caller is global-access or has no resolvable organization. Mirrors the
* scoping logic used by every entity `findAll`.
*/
export function tenantWhere(currentUser?: CurrentUser): {
organizationId?: string;
} {
const globalAccess = currentUser?.app_role?.globalAccess === true;
const organizationId =
currentUser?.organizations?.id ?? currentUser?.organizationId ?? null;
if (globalAccess || !organizationId) {
return {};
}
return { organizationId };
}
/**
* Finds a record by id, scoped to the caller's tenant. Returns null when the row
* is absent OR belongs to another organization (cross-tenant ids are
* indistinguishable from missing). Use in place of `model.findByPk(id)` for the
* lookup that precedes a tenant-owned update/remove/read-by-id.
*/
export async function findOwnedByPk<M extends Model>(
model: ModelStatic<M>,
id: string,
options?: DbApiOptions,
): Promise<M | null> {
const where: WhereOptions = { id, ...tenantWhere(options?.currentUser) };
return model.findOne({ where, transaction: options?.transaction });
}
/** Finds a record by id and soft-deletes it, scoped to the caller's tenant. */
export async function removeRecord<M extends Model>(
model: ModelStatic<M>,
id: string,
options?: DbApiOptions,
): Promise<M | null> {
const transaction = options?.transaction;
const record = await model.findByPk(id, { transaction });
const record = await findOwnedByPk(model, id, options);
if (!record) {
return null;
@ -32,14 +70,20 @@ export async function removeRecord<M extends Model>(
return record;
}
/** Deletes every record whose id is in `ids` (within the caller's transaction). */
/**
* Deletes every record whose id is in `ids` and belongs to the caller's tenant
* (within the caller's transaction). Cross-tenant ids are silently skipped.
*/
export async function deleteRecordsByIds<M extends Model>(
model: ModelStatic<M>,
ids: string[],
options?: DbApiOptions,
): Promise<M[]> {
const transaction = options?.transaction;
const where: WhereOptions = { id: { [Op.in]: ids } };
const where: WhereOptions = {
id: { [Op.in]: ids },
...tenantWhere(options?.currentUser),
};
const records = await model.findAll({ where, transaction });
@ -63,17 +107,20 @@ export async function autocompleteByField<M extends Model>(
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
let where: WhereOptions = {};
// Tenant scope (kept even when a query is present — see G3 in the
// tenant-isolation audit, where the query branch used to overwrite it).
const tenant: WhereOptions =
!globalAccess && organizationId ? { organizationId } : {};
if (!globalAccess && organizationId) {
where = { organizationId };
}
if (query) {
where = {
[Op.or]: [{ id: Utils.uuid(query) }, Utils.ilike(model.name, field, query)],
};
}
const where: WhereOptions = query
? {
...tenant,
[Op.or]: [
{ id: Utils.uuid(query) },
Utils.ilike(model.name, field, query),
],
}
: tenant;
const records = await model.findAll({
attributes: ['id', field],

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -138,7 +140,7 @@ class StaffDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const staff = await db.staff.findByPk(id, { transaction });
const staff = await findOwnedByPk(db.staff, id, options);
if (!staff) {
return null;
@ -204,7 +206,10 @@ class StaffDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const staff = await db.staff.findOne({ where, transaction });
const staff = await db.staff.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!staff) {
return null;
@ -216,7 +221,6 @@ class StaffDBApi {
classes_homeroom_teacher,
class_subjects_teacher,
attendance_sessions_taken_by,
payments_received_by,
organization,
campus,
user,
@ -225,7 +229,6 @@ class StaffDBApi {
staff.getClasses_homeroom_teacher({ transaction }),
staff.getClass_subjects_teacher({ transaction }),
staff.getAttendance_sessions_taken_by({ transaction }),
staff.getPayments_received_by({ transaction }),
staff.getOrganization({ transaction }),
staff.getCampus({ transaction }),
staff.getUser({ transaction }),
@ -234,7 +237,6 @@ class StaffDBApi {
output.classes_homeroom_teacher = classes_homeroom_teacher;
output.class_subjects_teacher = class_subjects_teacher;
output.attendance_sessions_taken_by = attendance_sessions_taken_by;
output.payments_received_by = payments_received_by;
output.organization = organization;
output.campus = campus;
output.user = user;

View File

@ -1,451 +0,0 @@
import {
Op,
type Includeable,
type InferAttributes,
type InferCreationAttributes,
type WhereAttributeHash,
} from 'sequelize';
import db from '@/db/models';
import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import Utils from '@/db/utils';
import FileDBApi from '@/db/api/file';
import type { Students } from '@/db/models/students';
import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types';
type StudentsData = Partial<InferCreationAttributes<Students>> & {
organization?: string | null;
campus?: string | null;
photo?: FileInput | FileInput[] | null;
};
interface StudentsFilter {
limit?: number | string;
page?: number | string;
id?: string;
student_number?: string;
first_name?: string;
last_name?: string;
email?: string;
phone?: string;
address?: string;
date_of_birthRange?: Array<string | null | undefined>;
enrollment_dateRange?: Array<string | null | undefined>;
active?: boolean | string;
gender?: string;
status?: string;
campus?: string;
organization?: string;
createdAtRange?: Array<string | null | undefined>;
field?: string;
sort?: string;
}
const NO_USER: CurrentUser = { id: null };
function studentsTableName(): string {
const name = db.students.getTableName();
return typeof name === 'string' ? name : name.tableName;
}
class StudentsDBApi {
static async create(
data: StudentsData,
options?: DbApiOptions,
): Promise<Students> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const students = await db.students.create(
{
id: data.id || undefined,
student_number: data.student_number || null,
first_name: data.first_name || null,
last_name: data.last_name || null,
gender: data.gender || null,
date_of_birth: data.date_of_birth || null,
enrollment_date: data.enrollment_date || null,
status: data.status || null,
email: data.email || null,
phone: data.phone || null,
address: data.address || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await students.setOrganization(currentUser.organizationId ?? undefined, {
transaction,
});
await students.setCampus(data.campus ?? undefined, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: studentsTableName(),
belongsToColumn: 'photo',
belongsToId: students.id,
},
data.photo,
options,
);
return students;
}
static async bulkImport(
data: StudentsData[],
options?: DbApiOptions,
): Promise<Students[]> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const studentsData = data.map((item, index) => ({
id: item.id || undefined,
student_number: item.student_number || null,
first_name: item.first_name || null,
last_name: item.last_name || null,
gender: item.gender || null,
date_of_birth: item.date_of_birth || null,
enrollment_date: item.enrollment_date || null,
status: item.status || null,
email: item.email || null,
phone: item.phone || null,
address: item.address || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS),
}));
const students = await db.students.bulkCreate(studentsData, { transaction });
for (let i = 0; i < students.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: studentsTableName(),
belongsToColumn: 'photo',
belongsToId: students[i].id,
},
data[i].photo,
options,
);
}
return students;
}
static async update(
id: string,
data: StudentsData,
options?: DbApiOptions,
): Promise<Students | null> {
const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const students = await db.students.findByPk(id, { transaction });
if (!students) {
return null;
}
const updatePayload: Partial<InferAttributes<Students>> = {};
if (data.student_number !== undefined)
updatePayload.student_number = data.student_number;
if (data.first_name !== undefined)
updatePayload.first_name = data.first_name;
if (data.last_name !== undefined) updatePayload.last_name = data.last_name;
if (data.gender !== undefined) updatePayload.gender = data.gender;
if (data.date_of_birth !== undefined)
updatePayload.date_of_birth = data.date_of_birth;
if (data.enrollment_date !== undefined)
updatePayload.enrollment_date = data.enrollment_date;
if (data.status !== undefined) updatePayload.status = data.status;
if (data.email !== undefined) updatePayload.email = data.email;
if (data.phone !== undefined) updatePayload.phone = data.phone;
if (data.address !== undefined) updatePayload.address = data.address;
updatePayload.updatedById = currentUser.id;
await students.update(updatePayload, { transaction });
if (data.organization !== undefined) {
const orgId = globalAccess
? data.organization
: currentUser.organizationId;
await students.setOrganization(orgId ?? undefined, { transaction });
}
if (data.campus !== undefined) {
await students.setCampus(data.campus ?? undefined, { transaction });
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: studentsTableName(),
belongsToColumn: 'photo',
belongsToId: students.id,
},
data.photo,
options,
);
return students;
}
static async deleteByIds(
ids: string[],
options?: DbApiOptions,
): Promise<Students[]> {
return deleteRecordsByIds(db.students, ids, options);
}
static async remove(
id: string,
options?: DbApiOptions,
): Promise<Students | null> {
return removeRecord(db.students, id, options);
}
static async findBy(
where: WhereAttributeHash,
options?: DbApiOptions,
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const students = await db.students.findOne({ where, transaction });
if (!students) {
return null;
}
const output: Record<string, unknown> = students.get({ plain: true });
const [
guardians_student,
class_enrollments_student,
attendance_records_student,
invoices_student,
assessment_results_student,
organization,
campus,
photo,
] = await Promise.all([
students.getGuardians_student({ transaction }),
students.getClass_enrollments_student({ transaction }),
students.getAttendance_records_student({ transaction }),
students.getInvoices_student({ transaction }),
students.getAssessment_results_student({ transaction }),
students.getOrganization({ transaction }),
students.getCampus({ transaction }),
students.getPhoto({ transaction }),
]);
output.guardians_student = guardians_student;
output.class_enrollments_student = class_enrollments_student;
output.attendance_records_student = attendance_records_student;
output.invoices_student = invoices_student;
output.assessment_results_student = assessment_results_student;
output.organization = organization;
output.campus = campus;
output.photo = photo;
return output;
}
static async findAll(
filter: StudentsFilter,
globalAccess: boolean,
options?: DbApiOptions,
): Promise<{ rows: Students[]; count: number }> {
const { limit, offset } = resolvePagination(filter.limit, filter.page);
let where: WhereAttributeHash = {};
const userOrganizations = options?.currentUser?.organizations?.id ?? null;
if (userOrganizations && options?.currentUser?.organizationId) {
where.organizationId = options.currentUser.organizationId;
}
const include: Includeable[] = [
{ model: db.organizations, as: 'organization' },
{
model: db.campuses,
as: 'campus',
where: filter.campus
? {
[Op.or]: [
{
id: {
[Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)),
},
},
{
name: {
[Op.or]: filter.campus
.split('|')
.map((t) => ({ [Op.iLike]: `%${t}%` })),
},
},
],
}
: {},
},
{ model: db.file, as: 'photo' },
];
if (filter.id) {
where = { ...where, id: Utils.uuid(filter.id) };
}
if (filter.student_number) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'student_number', filter.student_number),
};
}
if (filter.first_name) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'first_name', filter.first_name),
};
}
if (filter.last_name) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'last_name', filter.last_name),
};
}
if (filter.email) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'email', filter.email),
};
}
if (filter.phone) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'phone', filter.phone),
};
}
if (filter.address) {
where = {
...where,
[Op.and]: Utils.ilike('students', 'address', filter.address),
};
}
if (filter.date_of_birthRange) {
const [start, end] = filter.date_of_birthRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, date_of_birth: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
date_of_birth: {
...(typeof where.date_of_birth === 'object'
? where.date_of_birth
: {}),
[Op.lte]: end,
},
};
}
}
if (filter.enrollment_dateRange) {
const [start, end] = filter.enrollment_dateRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, enrollment_date: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
enrollment_date: {
...(typeof where.enrollment_date === 'object'
? where.enrollment_date
: {}),
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.gender) {
where = { ...where, gender: filter.gender };
}
if (filter.status) {
where = { ...where, status: filter.status };
}
if (filter.organization) {
const listItems = filter.organization
.split('|')
.map((item) => Utils.uuid(item));
where = { ...where, organizationId: { [Op.or]: listItems } };
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = { ...where, createdAt: { [Op.gte]: start } };
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...(typeof where.createdAt === 'object' ? where.createdAt : {}),
[Op.lte]: end,
},
};
}
}
if (globalAccess) {
delete where.organizationId;
}
const order: [string, string][] =
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']];
const { rows, count } = await db.students.findAndCountAll({
where,
include,
distinct: true,
order,
transaction: options?.transaction,
limit: !options?.countOnly && limit ? limit : undefined,
offset: !options?.countOnly && offset ? offset : undefined,
});
return { rows: options?.countOnly ? [] : rows, count };
}
static async findAllAutocomplete(
query: string | undefined,
limit: number | undefined,
offset: number | undefined,
globalAccess: boolean,
organizationId: string | undefined,
): Promise<Array<{ id: string; label: string | null }>> {
return autocompleteByField(
db.students,
'student_number',
query,
limit,
offset,
globalAccess,
organizationId,
);
}
}
export default StudentsDBApi;

View File

@ -9,6 +9,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -94,7 +96,7 @@ class SubjectsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const subjects = await db.subjects.findByPk(id, { transaction });
const subjects = await findOwnedByPk(db.subjects, id, options);
if (!subjects) {
return null;
@ -141,7 +143,10 @@ class SubjectsDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const subjects = await db.subjects.findOne({ where, transaction });
const subjects = await db.subjects.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!subjects) {
return null;

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -113,9 +115,7 @@ class Timetable_periodsDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const timetable_periods = await db.timetable_periods.findByPk(id, {
transaction,
});
const timetable_periods = await findOwnedByPk(db.timetable_periods, id, options);
if (!timetable_periods) {
return null;
@ -176,7 +176,7 @@ class Timetable_periodsDBApi {
const transaction = options?.transaction;
const timetable_periods = await db.timetable_periods.findOne({
where,
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});

View File

@ -10,6 +10,8 @@ import {
removeRecord,
deleteRecordsByIds,
autocompleteByField,
findOwnedByPk,
tenantWhere,
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
@ -108,7 +110,7 @@ class TimetablesDBApi {
const transaction = options?.transaction;
const globalAccess = currentUser.app_role?.globalAccess;
const timetables = await db.timetables.findByPk(id, { transaction });
const timetables = await findOwnedByPk(db.timetables, id, options);
if (!timetables) {
return null;
@ -165,7 +167,10 @@ class TimetablesDBApi {
): Promise<Record<string, unknown> | null> {
const transaction = options?.transaction;
const timetables = await db.timetables.findOne({ where, transaction });
const timetables = await db.timetables.findOne({
where: { ...where, ...tenantWhere(options?.currentUser) },
transaction,
});
if (!timetables) {
return null;

View File

@ -20,6 +20,7 @@ export interface PermissionLike {
export interface UserProfileRecord {
id: string;
email: string;
name_prefix: string | null;
firstName: string | null;
lastName: string | null;
organizationId: string | null;
@ -62,6 +63,7 @@ export interface CurrentUser {
*/
password?: string | null;
custom_permissions?: PermissionLike[] | null;
name_prefix?: string | null;
firstName?: string | null;
lastName?: string | null;
email?: string | null;

View File

@ -14,7 +14,6 @@ import {
} from '@/db/api/shared/repository';
import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database';
import { resolvePagination } from '@/shared/constants/pagination';
import { SPECIAL_ROLE_NAMES } from '@/shared/constants/roles';
import {
EMAIL_ACTION_TOKEN_BYTES,
EMAIL_ACTION_TOKEN_TTL_MS,
@ -101,6 +100,7 @@ class UsersDBApi {
const users = await db.users.create(
{
id: data.id || undefined,
name_prefix: data.name_prefix || null,
firstName: data.firstName || null,
lastName: data.lastName || null,
phoneNumber: data.phoneNumber || null,
@ -115,20 +115,16 @@ class UsersDBApi {
passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null,
provider: data.provider || null,
importHash: data.importHash || null,
campusId: data.campusId || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
if (!data.app_role) {
const role = await db.roles.findOne({
where: { name: SPECIAL_ROLE_NAMES.DEFAULT_USER },
});
if (role) {
await users.setApp_role(role, { transaction });
}
} else {
// Roles are assigned explicitly by the provisioning flow; a user created
// without one has no role and falls back to `guest` until assigned.
if (data.app_role) {
await users.setApp_role(data.app_role, { transaction });
}
@ -161,6 +157,7 @@ class UsersDBApi {
const usersData = data.map((item, index) => ({
id: item.id || undefined,
name_prefix: item.name_prefix || null,
firstName: item.firstName || null,
lastName: item.lastName || null,
phoneNumber: item.phoneNumber || null,
@ -214,6 +211,8 @@ class UsersDBApi {
const updatePayload: Partial<InferAttributes<Users>> = {};
if (data.name_prefix !== undefined)
updatePayload.name_prefix = data.name_prefix;
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
if (data.phoneNumber !== undefined)
@ -234,6 +233,7 @@ class UsersDBApi {
updatePayload.passwordResetTokenExpiresAt =
data.passwordResetTokenExpiresAt;
if (data.provider !== undefined) updatePayload.provider = data.provider;
if (data.campusId !== undefined) updatePayload.campusId = data.campusId;
updatePayload.updatedById = currentUser.id;
@ -412,6 +412,7 @@ class UsersDBApi {
return {
id: user.id,
email: user.email,
name_prefix: user.name_prefix ?? null,
firstName: user.firstName,
lastName: user.lastName,
organizationId: user.organizationId,

View File

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

View File

@ -25,18 +25,9 @@ CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, "
DO 'BEGIN CREATE TYPE "public"."enum_communication_events_event_type" AS ENUM(''meeting'', ''drill'', ''event'', ''deadline''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "content_catalog" ("id" UUID , "content_type" TEXT NOT NULL UNIQUE, "payload" JSONB NOT NULL, "active" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_documents_entity_type" AS ENUM(''student'', ''staff'', ''class'', ''invoice'', ''organization'', ''campus'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END';
DO 'BEGIN CREATE TYPE "public"."enum_documents_category" AS ENUM(''policy'', ''report'', ''id'', ''medical'', ''consent'', ''invoice'', ''receipt'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "documents" ("id" UUID , "entity_type" "public"."enum_documents_entity_type", "entity_reference" TEXT, "name" TEXT, "category" "public"."enum_documents_category", "uploaded_at" TIMESTAMP WITH TIME ZONE, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_fee_plans_billing_cycle" AS ENUM(''one_time'', ''monthly'', ''termly'', ''annual''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "fee_plans" ("id" UUID , "name" TEXT, "billing_cycle" "public"."enum_fee_plans_billing_cycle", "total_amount" DECIMAL, "active" BOOLEAN NOT NULL DEFAULT false, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "organizationId" UUID, "gradeId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "files" ("id" UUID , "belongsTo" VARCHAR(255), "belongsToId" UUID, "belongsToColumn" VARCHAR(255), "name" VARCHAR(2083) NOT NULL, "sizeInBytes" INTEGER, "privateUrl" VARCHAR(2083), "publicUrl" VARCHAR(2083) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "frame_entries" ("id" UUID , "week_of" TEXT NOT NULL, "posted_date" TEXT NOT NULL, "formal" TEXT NOT NULL, "recognition" TEXT NOT NULL, "application" TEXT NOT NULL, "management" TEXT NOT NULL, "emotional" TEXT NOT NULL, "author" TEXT NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "grades" ("id" UUID , "name" TEXT, "code" TEXT, "sort_order" INTEGER, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_guardians_relationship" AS ENUM(''mother'', ''father'', ''guardian'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "guardians" ("id" UUID , "full_name" TEXT, "relationship" "public"."enum_guardians_relationship", "phone" TEXT, "email" TEXT, "address" TEXT, "primary_contact" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_invoices_status" AS ENUM(''draft'', ''issued'', ''partially_paid'', ''paid'', ''overdue'', ''void''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "invoices" ("id" UUID , "invoice_number" TEXT, "issue_date" TIMESTAMP WITH TIME ZONE, "due_date" TIMESTAMP WITH TIME ZONE, "subtotal" DECIMAL, "discount_amount" DECIMAL, "tax_amount" DECIMAL, "total_amount" DECIMAL, "balance_due" DECIMAL, "status" "public"."enum_invoices_status", "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "fee_planId" UUID, "organizationId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_recipient_type" AS ENUM(''user'', ''student'', ''guardian''); EXCEPTION WHEN duplicate_object THEN null; END';
DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_delivery_status" AS ENUM(''pending'', ''sent'', ''delivered'', ''failed'', ''read''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "message_recipients" ("id" UUID , "recipient_type" "public"."enum_message_recipients_recipient_type", "recipient_label" TEXT, "destination" TEXT, "delivery_status" "public"."enum_message_recipients_delivery_status", "delivered_at" TIMESTAMP WITH TIME ZONE, "read_at" TIMESTAMP WITH TIME ZONE, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "messageId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -45,8 +36,6 @@ DO 'BEGIN CREATE TYPE "public"."enum_messages_audience" AS ENUM(''all_org'', ''c
DO 'BEGIN CREATE TYPE "public"."enum_messages_status" AS ENUM(''draft'', ''scheduled'', ''sent'', ''failed''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_payments_method" AS ENUM(''cash'', ''bank_transfer'', ''card'', ''mobile_money'', ''cheque'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "payments" ("id" UUID , "receipt_number" TEXT, "paid_at" TIMESTAMP WITH TIME ZONE, "amount" DECIMAL, "method" "public"."enum_payments_method", "reference_code" TEXT, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "invoiceId" UUID, "organizationId" UUID, "received_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -56,9 +45,6 @@ DO 'BEGIN CREATE TYPE "public"."enum_staff_status" AS ENUM(''active'', ''on_leav
CREATE TABLE IF NOT EXISTS "staff" ("id" UUID , "employee_number" TEXT, "job_title" TEXT, "staff_type" "public"."enum_staff_staff_type", "hire_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_staff_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "staff_attendance_records" ("id" UUID , "attendance_date" DATE NOT NULL, "status" "public"."enum_staff_attendance_records_status" NOT NULL, "note" TEXT, "user_name" TEXT NOT NULL, "user_role" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_students_gender" AS ENUM(''male'', ''female'', ''other'', ''prefer_not_to_say''); EXCEPTION WHEN duplicate_object THEN null; END';
DO 'BEGIN CREATE TYPE "public"."enum_students_status" AS ENUM(''prospect'', ''enrolled'', ''inactive'', ''graduated'', ''transferred''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "students" ("id" UUID , "student_number" TEXT, "first_name" TEXT, "last_name" TEXT, "gender" "public"."enum_students_gender", "date_of_birth" TIMESTAMP WITH TIME ZONE, "enrollment_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_students_status", "email" TEXT, "phone" TEXT, "address" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
CREATE TABLE IF NOT EXISTS "subjects" ("id" UUID , "name" TEXT, "code" TEXT, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
DO 'BEGIN CREATE TYPE "public"."enum_timetable_periods_day_of_week" AS ENUM(''monday'', ''tuesday'', ''wednesday'', ''thursday'', ''friday'', ''saturday'', ''sunday''); EXCEPTION WHEN duplicate_object THEN null; END';
CREATE TABLE IF NOT EXISTS "timetable_periods" ("id" UUID , "day_of_week" "public"."enum_timetable_periods_day_of_week", "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "room" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "class_subjectId" UUID, "organizationId" UUID, "timetableId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id"));
@ -79,24 +65,18 @@ DROP TABLE IF EXISTS "user_progress" CASCADE;
DROP TABLE IF EXISTS "timetables" CASCADE;
DROP TABLE IF EXISTS "timetable_periods" CASCADE;
DROP TABLE IF EXISTS "subjects" CASCADE;
DROP TABLE IF EXISTS "students" CASCADE;
DROP TABLE IF EXISTS "staff_attendance_records" CASCADE;
DROP TABLE IF EXISTS "staff" CASCADE;
DROP TABLE IF EXISTS "safety_quiz_results" CASCADE;
DROP TABLE IF EXISTS "roles" CASCADE;
DROP TABLE IF EXISTS "personality_quiz_results" CASCADE;
DROP TABLE IF EXISTS "permissions" CASCADE;
DROP TABLE IF EXISTS "payments" CASCADE;
DROP TABLE IF EXISTS "organizations" CASCADE;
DROP TABLE IF EXISTS "messages" CASCADE;
DROP TABLE IF EXISTS "message_recipients" CASCADE;
DROP TABLE IF EXISTS "invoices" CASCADE;
DROP TABLE IF EXISTS "guardians" CASCADE;
DROP TABLE IF EXISTS "grades" CASCADE;
DROP TABLE IF EXISTS "frame_entries" CASCADE;
DROP TABLE IF EXISTS "files" CASCADE;
DROP TABLE IF EXISTS "fee_plans" CASCADE;
DROP TABLE IF EXISTS "documents" CASCADE;
DROP TABLE IF EXISTS "content_catalog" CASCADE;
DROP TABLE IF EXISTS "communication_events" CASCADE;
DROP TABLE IF EXISTS "classes" CASCADE;
@ -121,22 +101,14 @@ DROP TYPE IF EXISTS "public"."enum_class_enrollments_status";
DROP TYPE IF EXISTS "public"."enum_class_subjects_status";
DROP TYPE IF EXISTS "public"."enum_classes_status";
DROP TYPE IF EXISTS "public"."enum_communication_events_event_type";
DROP TYPE IF EXISTS "public"."enum_documents_entity_type";
DROP TYPE IF EXISTS "public"."enum_documents_category";
DROP TYPE IF EXISTS "public"."enum_fee_plans_billing_cycle";
DROP TYPE IF EXISTS "public"."enum_guardians_relationship";
DROP TYPE IF EXISTS "public"."enum_invoices_status";
DROP TYPE IF EXISTS "public"."enum_message_recipients_recipient_type";
DROP TYPE IF EXISTS "public"."enum_message_recipients_delivery_status";
DROP TYPE IF EXISTS "public"."enum_messages_channel";
DROP TYPE IF EXISTS "public"."enum_messages_audience";
DROP TYPE IF EXISTS "public"."enum_messages_status";
DROP TYPE IF EXISTS "public"."enum_payments_method";
DROP TYPE IF EXISTS "public"."enum_staff_staff_type";
DROP TYPE IF EXISTS "public"."enum_staff_status";
DROP TYPE IF EXISTS "public"."enum_staff_attendance_records_status";
DROP TYPE IF EXISTS "public"."enum_students_gender";
DROP TYPE IF EXISTS "public"."enum_students_status";
DROP TYPE IF EXISTS "public"."enum_timetable_periods_day_of_week";
DROP TYPE IF EXISTS "public"."enum_timetables_status";
DROP TYPE IF EXISTS "public"."enum_user_progress_progress_type";

View File

@ -0,0 +1,35 @@
import { DataTypes, type QueryInterface } from 'sequelize';
import { ROLE_SCOPE_VALUES } from '@/shared/constants/roles';
/**
* Workstream 3 §3.1 foundation: add the authorization `scope` to `roles` (NOT
* NULL every role has a scope; the 11 first-class roles are seeded with one)
* and a nullable `campusId` to `users` (campus scope for campus-bound roles;
* null for system/organization scopes, so legitimately optional).
*
* Pre-launch with no production data, the `scope` column is added NOT NULL
* against the (empty, freshly-migrated) `roles` table; seeders populate it.
* `campusId` is a plain UUID (no DB-level FK), matching how
* `users.organizationId` is modeled associations use `constraints: false`.
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('roles', 'scope', {
type: DataTypes.ENUM(...ROLE_SCOPE_VALUES),
allowNull: false,
});
await queryInterface.addColumn('users', 'campusId', {
type: DataTypes.UUID,
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('users', 'campusId');
await queryInterface.removeColumn('roles', 'scope');
// Postgres keeps the enum type after the column is dropped; remove it too.
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_roles_scope";',
);
},
};

View File

@ -0,0 +1,83 @@
import { DataTypes, type QueryInterface } from 'sequelize';
import { POLICY_DOCUMENT_CATEGORY_VALUES } from '@/shared/constants/policy-documents';
/**
* Workstream 11 policy/safety acknowledgment persistence.
*
* `policy_documents`: tenant/campus-scoped documents (safety protocols + handbook
* policies) that director/office_manager manage. `version` bumps on change so a
* new version requires re-acknowledgment.
*
* `policy_acknowledgments`: one row per (user, document, version) a campus
* staff member's acknowledgment of a specific document version. Unique on
* (userId, policyDocumentId, version). Plain UUID references (no DB-level FK),
* matching the rest of the schema (associations use `constraints: false`).
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('policy_documents', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
body: { type: DataTypes.TEXT, allowNull: true },
category: {
type: DataTypes.ENUM(...POLICY_DOCUMENT_CATEGORY_VALUES),
allowNull: false,
},
// Optional finer sub-category within a category (e.g. the Handbook &
// Policies page's Operations/Behavior/Safety/Communication/Legal tag).
tag: { type: DataTypes.STRING(255), allowNull: true },
// Display name of the staff member who created the entry (shown as "by …").
author: { type: DataTypes.STRING(255), allowNull: true },
// Author-filled structured content (safety protocols): ordered procedure
// steps and autism-specific considerations. Null for handbook policies.
steps: { type: DataTypes.JSONB, allowNull: true },
autism_considerations: { type: DataTypes.JSONB, allowNull: true },
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
});
await queryInterface.createTable('policy_acknowledgments', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
policyDocumentId: { type: DataTypes.UUID, allowNull: false },
version: { type: DataTypes.INTEGER, allowNull: false },
userId: { type: DataTypes.UUID, allowNull: false },
acknowledgedAt: { type: DataTypes.DATE, allowNull: false },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
});
await queryInterface.addIndex('policy_acknowledgments', {
fields: ['userId', 'policyDocumentId', 'version'],
unique: true,
name: 'policy_acknowledgments_user_document_version_unique',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('policy_acknowledgments');
await queryInterface.dropTable('policy_documents');
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_policy_documents_category";',
);
},
};

View File

@ -0,0 +1,46 @@
import { DataTypes, type QueryInterface } from 'sequelize';
/**
* Workstream 13 audio library. `director`/`office_manager`/`teacher` upload
* audio files that any campus staff can pick to play in the classroom timer.
*
* Uploaded files are **campus-scoped** (`organizationId` + `campusId` set). The
* existing built-in timer sounds remain the **global defaults** served from the
* (global) `content_catalog`, so they are already available to every org and are
* not duplicated here. `is_default` + a null `organizationId` are kept so a
* platform admin could later add global audio rows; campus uploads are not
* default.
*
* The binary itself is stored via the existing JWT-authenticated file subsystem
* (`POST /api/file/upload/...`); `url` holds the returned reference.
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('audio_files', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: { type: DataTypes.TEXT, allowNull: false },
url: { type: DataTypes.STRING(2083), allowNull: false },
is_default: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
organizationId: { type: DataTypes.UUID, allowNull: true },
campusId: { type: DataTypes.UUID, allowNull: true },
createdById: { type: DataTypes.UUID, allowNull: true },
updatedById: { type: DataTypes.UUID, allowNull: true },
createdAt: { type: DataTypes.DATE },
updatedAt: { type: DataTypes.DATE },
deletedAt: { type: DataTypes.DATE },
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('audio_files');
},
};

View File

@ -0,0 +1,23 @@
import { DataTypes, type QueryInterface } from 'sequelize';
import { USER_NAME_PREFIX_VALUES } from '@/shared/constants/users';
/**
* Add an honorific name prefix (title) to users `Mr.` / `Ms.` / `Dr.` etc.
* Lets the UI render "Dr. Williams" without the title being baked into the
* person's first name. Nullable; no production data (pre-launch reset).
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('users', 'name_prefix', {
type: DataTypes.ENUM(...USER_NAME_PREFIX_VALUES),
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.removeColumn('users', 'name_prefix');
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_users_name_prefix";',
);
},
};

View File

@ -0,0 +1,44 @@
import { DataTypes, type QueryInterface } from 'sequelize';
import { AUDIO_FILE_KINDS } from '@/shared/constants/audio-files';
/**
* Workstream 13 generalize `audio_files` into a flexible sound library. A row
* is now one of three kinds (`file` | `url` | `recipe`):
* - `file` / `url` populate `url` (uploaded binary or external link);
* - `recipe` populates the new `recipe` JSONB (client-synthesized sound) and
* leaves `url` null so `url` becomes nullable.
*
* `recipe` rows are played purely via the Web Audio API in the browser; they
* never reference the file subsystem and so are exempt from the download
* ownership check.
*/
export default {
up: async (queryInterface: QueryInterface) => {
await queryInterface.addColumn('audio_files', 'kind', {
type: DataTypes.ENUM(...AUDIO_FILE_KINDS),
allowNull: false,
defaultValue: 'file',
});
await queryInterface.addColumn('audio_files', 'recipe', {
type: DataTypes.JSONB,
allowNull: true,
});
await queryInterface.changeColumn('audio_files', 'url', {
type: DataTypes.STRING(2083),
allowNull: true,
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.changeColumn('audio_files', 'url', {
type: DataTypes.STRING(2083),
allowNull: false,
});
await queryInterface.removeColumn('audio_files', 'recipe');
await queryInterface.removeColumn('audio_files', 'kind');
// Drop the enum type left behind by the ENUM column (Postgres).
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_audio_files_kind";',
);
},
};

View File

@ -14,7 +14,6 @@ import type {
HasManySetAssociationsMixin,
} from 'sequelize';
import type { Classes } from './classes';
import type { FeePlans } from './fee_plans';
import type { Organizations } from './organizations';
import type { Timetables } from './timetables';
import type { Users } from './users';
@ -41,8 +40,6 @@ export class AcademicYears extends Model<
declare setClasses_academic_year: HasManySetAssociationsMixin<Classes, string>;
declare getTimetables_academic_year: HasManyGetAssociationsMixin<Timetables>;
declare setTimetables_academic_year: HasManySetAssociationsMixin<Timetables, string>;
declare getFee_plans_academic_year: HasManyGetAssociationsMixin<FeePlans>;
declare setFee_plans_academic_year: HasManySetAssociationsMixin<FeePlans, string>;
declare getOrganization: BelongsToGetAssociationMixin<Organizations>;
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
@ -90,14 +87,6 @@ export class AcademicYears extends Model<
db.academic_years.hasMany(db.fee_plans, {
as: 'fee_plans_academic_year',
foreignKey: {
name: 'academic_yearId',
},
constraints: false,
});

View File

@ -13,7 +13,6 @@ import type {
} from 'sequelize';
import type { Assessments } from './assessments';
import type { Organizations } from './organizations';
import type { Students } from './students';
import type { Users } from './users';
export class AssessmentResults extends Model<
@ -39,8 +38,6 @@ export class AssessmentResults extends Model<
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getAssessment: BelongsToGetAssociationMixin<Assessments>;
declare setAssessment: BelongsToSetAssociationMixin<Assessments, string>;
declare getStudent: BelongsToGetAssociationMixin<Students>;
declare setStudent: BelongsToSetAssociationMixin<Students, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -99,14 +96,6 @@ export class AssessmentResults extends Model<
constraints: false,
});
db.assessment_results.belongsTo(db.students, {
as: 'student',
foreignKey: {
name: 'studentId',
},
constraints: false,
});

View File

@ -13,7 +13,6 @@ import type {
} from 'sequelize';
import type { AttendanceSessions } from './attendance_sessions';
import type { Organizations } from './organizations';
import type { Students } from './students';
import type { Users } from './users';
export class AttendanceRecords extends Model<
@ -39,8 +38,6 @@ export class AttendanceRecords extends Model<
declare setOrganization: BelongsToSetAssociationMixin<Organizations, string>;
declare getAttendance_session: BelongsToGetAssociationMixin<AttendanceSessions>;
declare setAttendance_session: BelongsToSetAssociationMixin<AttendanceSessions, string>;
declare getStudent: BelongsToGetAssociationMixin<Students>;
declare setStudent: BelongsToSetAssociationMixin<Students, string>;
declare getCreatedBy: BelongsToGetAssociationMixin<Users>;
declare setCreatedBy: BelongsToSetAssociationMixin<Users, string>;
declare getUpdatedBy: BelongsToGetAssociationMixin<Users>;
@ -99,14 +96,6 @@ export class AttendanceRecords extends Model<
constraints: false,
});
db.attendance_records.belongsTo(db.students, {
as: 'student',
foreignKey: {
name: 'studentId',
},
constraints: false,
});

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