backend migration
This commit is contained in:
parent
d4a5378adf
commit
0d36322be9
@ -1,3 +1,8 @@
|
||||
backend/node_modules
|
||||
backend/dist
|
||||
backend/public
|
||||
frontend/node_modules
|
||||
frontend/build
|
||||
frontend/dist
|
||||
**/.env
|
||||
**/.git
|
||||
**/*.log
|
||||
|
||||
146
CLAUDE.md
146
CLAUDE.md
@ -1,30 +1,132 @@
|
||||
# Repository Guidelines
|
||||
# CLAUDE.md
|
||||
|
||||
**MAIN RULE:** DON'T MADE UP ANYTHING!!! IF YOU NOT SURE - DOUBLECHECK IT VIA PROJECT DOCUMENTATION, TOOL DOCUMENTATION, APPROPRIATE MCP, WEB SEARCH OR JUST ASK FOR ME TO CLARIFY.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**MAIN RULE:** DON'T MAKE UP ANYTHING. If unsure, verify via project documentation, MCP tools, web search, or ask for clarification.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (from `backend/`)
|
||||
```bash
|
||||
npm run dev # Development server with hot reload (tsx watch)
|
||||
npm run verify # Run typecheck + lint + tests (use before commits)
|
||||
npm run typecheck # TypeScript check only
|
||||
npm run lint # ESLint only
|
||||
npm run test # Node test runner (tsx --test)
|
||||
npm run build # Production build → dist/
|
||||
|
||||
# Database
|
||||
npm run db:migrate # Run pending migrations
|
||||
npm run db:seed # Run seeders
|
||||
npm run db:reset # Drop all → migrate → seed (destroys data)
|
||||
npm run start # migrate + seed + watch (dev startup)
|
||||
```
|
||||
|
||||
### Frontend (from `frontend/`)
|
||||
```bash
|
||||
npm run dev # Vite dev server (port 3000)
|
||||
npm run build # Typecheck + Vite production build
|
||||
npm run typecheck # TypeScript check only
|
||||
npm run lint # ESLint only
|
||||
npm run test # Vitest unit tests
|
||||
npm run test:e2e # Playwright smoke tests (no backend)
|
||||
npm run test:e2e:content # Playwright tests (requires backend running)
|
||||
```
|
||||
|
||||
### Root-level
|
||||
```bash
|
||||
npm run build:production # Build both frontend and backend
|
||||
npm run start:production # Start production backend (serves API + SPA)
|
||||
```
|
||||
|
||||
### Docker (from `docker/`)
|
||||
```bash
|
||||
docker compose up --build # PostgreSQL + app on localhost:8080
|
||||
docker compose down -v # Stop and remove (including DB data)
|
||||
```
|
||||
|
||||
### Quick Start (Dev Mode)
|
||||
|
||||
Prerequisites: PostgreSQL running locally with database `schoolchain_dev` and `backend/.env` configured.
|
||||
|
||||
```bash
|
||||
# Terminal 1 - Backend (port 8080)
|
||||
cd backend
|
||||
export $(grep -v '^#' .env | xargs) && npm run dev
|
||||
|
||||
# Terminal 2 - Frontend (port 3000)
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Layer Pattern (Both Frontend and Backend)
|
||||
|
||||
**Backend** (`backend/src/`):
|
||||
```
|
||||
API Layer → routes/*.ts, api/controllers/*.ts, middlewares/*.ts
|
||||
Business Layer → services/*.ts (static class methods)
|
||||
Data Layer → db/api/*.ts (repositories), db/models/*.ts (Sequelize)
|
||||
```
|
||||
|
||||
**Frontend** (`frontend/src/`):
|
||||
```
|
||||
View Layer → pages/, components/
|
||||
Business Layer → business/<module>/ (hooks, mappers, selectors)
|
||||
Data Layer → shared/api/*.ts, shared/types/*.ts
|
||||
```
|
||||
|
||||
Import direction: `API → Business → Data`. Never skip layers. Cross-cutting code lives in `shared/` and can be imported by any layer.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**CRUD Modules**: Most entities use shared factories:
|
||||
- `services/shared/crud-service.ts` → one-line service config
|
||||
- `api/controllers/shared/crud-controller.ts` → one-line controller config
|
||||
- `api/http/crud-router.ts` → one-line router config
|
||||
|
||||
**Error Handling**: Throw `AppError` subclasses from `shared/errors/`. The terminal `error-handler` middleware formats responses.
|
||||
|
||||
**Authentication**: Backend-owned HttpOnly cookie auth. Frontend uses `AuthContext` as a thin provider; refresh/retry logic in `shared/api/httpClient.ts`.
|
||||
|
||||
**Import Aliases**: Use `@/*` for all imports (maps to `./src/*` in both projects).
|
||||
|
||||
## Working Principles
|
||||
|
||||
1. **Check docs first**: Read relevant documents files before starting tasks (links provided below)
|
||||
2. **Minimal changes**: Update only necessary files, prefer simple robust solutions
|
||||
3. **Use SKILLS**: For best results in specific areas, use the appropriate **SKILLS** in `.codex/skills` (56 skills available)
|
||||
4. **Agents orchestration**: Use `.codex/skills/ai-agent-orchestrator/SKILL.md` skill to improve your efficiency.
|
||||
5. **Use MCP servers**: Available via `mcp__<server>__<tool>`: GitHub (`github`), Chrome DevTools (`chrome-devtools`)
|
||||
6. **TypeScript strict mode**: Avoid `any` types, **NEVER** disable linter or TypeScript, **NEVER** use types casting
|
||||
7. **Concise comments**: Explain "why" not "what", code should be self-documenting
|
||||
8. **No over-engineering**: Build for a small SaaS used by owner-operators. Choose the simplest robust solution that is fast to implement, easy to understand, and easy to support. Avoid enterprise-style architecture and unnecessary complexity: no extra fallbacks, premature abstractions, microservices, Kubernetes, complex orchestration, heavy caching, or distributed-system patterns unless the task explicitly requires them. User-facing copy must use plain language and avoid accounting jargon.
|
||||
9. **Native errors for external services**: Pass through errors from Gemini/OpenAI as-is
|
||||
10. **Centralized exceptions only**: Always use centralized exceptions instead of inlined logs or exceptions
|
||||
11. **Use tools and agents**: Use MCP servers, web search, and agents when needed
|
||||
12. **Avoid hardcoded constants**: Add to `backend/src/constants/` or `frontend/src/shared/constants/`
|
||||
13. **Avoid silent failures**: Provide proper observability for all failure modes
|
||||
14. **Documentation matters**: After **EACH** task update appropriate documentation files AND for **EACH** new module or feature create documentation file in appropriate directory: `backend/docs/`, `frontend/docs/` or common `docs/`
|
||||
15. **Aliases for imports**: For **ALL** imports use aliases `@` instead of absolute or relative paths
|
||||
16. **Tests matters**: After **EACH** task update appropriate unit and e2e tests AND for **EACH** new functionality create new tests (unit or e2e depending on the feature’s complexity and importance)
|
||||
1. **Check docs first**: Read relevant docs before starting (see Documentation Entry Points below)
|
||||
2. **Minimal changes**: Only update necessary files, prefer simple robust solutions
|
||||
3. **TypeScript strict mode**: No `any` types, never disable linter/TypeScript, no type casting
|
||||
4. **Concise comments**: Explain "why" not "what"
|
||||
5. **No over-engineering**: Build for small SaaS. Simplest robust solution. No enterprise patterns unless required.
|
||||
6. **Centralized exceptions only**: Use `AppError` subclasses from `shared/errors/`
|
||||
7. **Avoid hardcoded constants**: Add to `backend/src/shared/constants/` or `frontend/src/shared/constants/`
|
||||
8. **Documentation matters**: Update docs after each task; create docs for new modules
|
||||
9. **Tests matter**: Update tests after each task; create tests for new functionality
|
||||
|
||||
## Documentation Entry Points
|
||||
|
||||
- Frontend documentation index: `frontend/docs/index.md`
|
||||
- Frontend architecture contract: `frontend/docs/frontend-architecture.md`
|
||||
- Backend and cross-project integration plan: `docs/full-integration-refactor-plan.md`
|
||||
- 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`
|
||||
- VM deployment: `docs/deployment-vm.md`
|
||||
- Docker deployment: `docs/deployment-docker.md`
|
||||
|
||||
For frontend work, read `frontend/docs/frontend-architecture.md` before implementation and follow its three-layer approach: view components, business logic, and API/data access.
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Node 24, Express 5, Sequelize 6, TypeScript 6, PostgreSQL
|
||||
- **Frontend**: React 19, Vite 8, TypeScript 6, Tailwind 4, React Query, Vitest, Playwright
|
||||
- **Both**: ESM modules, strict TypeScript, path aliases (`@/*`)
|
||||
|
||||
## MCP Servers Available
|
||||
|
||||
- `github` - GitHub operations
|
||||
- `chrome-devtools` - Browser DevTools
|
||||
- `posthog` - Analytics (project: TRO Matcher)
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@ -1,21 +1,29 @@
|
||||
FROM node:20.15.1-alpine AS builder
|
||||
# --- Frontend build ---
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend .
|
||||
RUN yarn build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
|
||||
FROM node:20.15.1-alpine
|
||||
# --- Backend build (TypeScript -> dist; bcrypt compiles natively) ---
|
||||
FROM node:24-alpine AS backend-builder
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app
|
||||
COPY backend/package.json backend/yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY backend .
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
COPY --from=builder /app/build /app/public
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
|
||||
# --- Runtime (compiled, production deps only) ---
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=backend-builder /app/node_modules ./node_modules
|
||||
COPY --from=backend-builder /app/dist ./dist
|
||||
COPY --from=backend-builder /app/package.json ./package.json
|
||||
COPY --from=frontend-builder /app/dist ./public
|
||||
|
||||
# Runs migrations and seeders (compiled) before starting the server.
|
||||
CMD ["npm", "run", "start:production"]
|
||||
|
||||
107
Dockerfile.dev
107
Dockerfile.dev
@ -1,85 +1,52 @@
|
||||
# Base image for Node.js dependencies
|
||||
FROM node:20.15.1-alpine AS frontend-deps
|
||||
# Staging replica of the VM: production builds of both frontend and backend,
|
||||
# NODE_ENV=dev_stage (verbose logging + source maps for debuggability), behind
|
||||
# nginx — exactly like the deployed VM. nginx (8080) routes `/` to the frontend
|
||||
# (vite preview, 3001) and `/api` to the backend (compiled, 3000 in dev_stage).
|
||||
# dev_stage forces secure cookies, so run this behind HTTPS (e.g. a tunnel).
|
||||
|
||||
# --- Frontend build (production) ---
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20.15.1-alpine AS backend-deps
|
||||
RUN apk add --no-cache git
|
||||
# --- Backend build (TypeScript -> dist; bcrypt compiles natively) ---
|
||||
FROM node:24-alpine AS backend-builder
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY backend .
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:20.15.1-alpine AS app-shell-deps
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app/app-shell
|
||||
COPY app-shell/package.json app-shell/yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
# --- Runtime: nginx + compiled backend (3000) + frontend prod preview (3001) ---
|
||||
FROM node:24-alpine
|
||||
RUN apk add --no-cache nginx
|
||||
ENV NODE_ENV=dev_stage
|
||||
ENV FRONT_PORT=3001
|
||||
|
||||
# Nginx setup and application build
|
||||
FROM node:20.15.1-alpine AS build
|
||||
RUN apk add --no-cache git nginx curl
|
||||
RUN apk add --no-cache lsof procps
|
||||
RUN yarn global add concurrently
|
||||
# Backend: compiled output + production-only deps (no native recompile here).
|
||||
WORKDIR /app/backend
|
||||
COPY --from=backend-builder /app/backend/node_modules ./node_modules
|
||||
COPY --from=backend-builder /app/backend/dist ./dist
|
||||
COPY --from=backend-builder /app/backend/package.json ./package.json
|
||||
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ttf-freefont \
|
||||
fontconfig
|
||||
# Frontend: built assets + deps/config needed to serve them via `vite preview`.
|
||||
WORKDIR /app/frontend
|
||||
COPY --from=frontend-builder /app/frontend/node_modules ./node_modules
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
COPY --from=frontend-builder /app/frontend/package.json ./package.json
|
||||
COPY --from=frontend-builder /app/frontend/vite.config.ts ./vite.config.ts
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
RUN mkdir -p /app/pids
|
||||
|
||||
# Make sure to add yarn global bin to PATH
|
||||
ENV PATH /root/.yarn/bin:/root/.config/yarn/global/node_modules/.bin:$PATH
|
||||
|
||||
# Copy dependencies
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-deps /app/frontend /app/frontend
|
||||
COPY --from=backend-deps /app/backend /app/backend
|
||||
COPY --from=app-shell-deps /app/app-shell /app/app-shell
|
||||
|
||||
COPY frontend /app/frontend
|
||||
COPY backend /app/backend
|
||||
COPY app-shell /app/app-shell
|
||||
COPY docker /app/docker
|
||||
|
||||
# Copy all files from root to /app
|
||||
COPY . /app
|
||||
|
||||
# Copy Nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy custom error page
|
||||
COPY 502.html /usr/share/nginx/html/502.html
|
||||
|
||||
# Change owner and permissions of the error page
|
||||
RUN chown nginx:nginx /usr/share/nginx/html/502.html && \
|
||||
chmod 644 /usr/share/nginx/html/502.html
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 8080
|
||||
ENV NODE_ENV=dev_stage
|
||||
ENV FRONT_PORT=3001
|
||||
ENV BACKEND_PORT=3000
|
||||
ENV APP_SHELL_PORT=4000
|
||||
|
||||
|
||||
CMD ["sh", "-c", "\
|
||||
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \
|
||||
yarn --cwd /app/backend start & echo $! > /app/pids/backend.pid && \
|
||||
sleep 10 && nginx -g 'daemon off;' & \
|
||||
NGINX_PID=$! && \
|
||||
echo 'Waiting for backend (port 3000) to be available...' && \
|
||||
while ! nc -z localhost ${BACKEND_PORT}; do \
|
||||
sleep 2; \
|
||||
done && \
|
||||
echo 'Backend is up. Starting app_shell for Git check...' && \
|
||||
yarn --cwd /app/app-shell start && \
|
||||
wait $NGINX_PID"]
|
||||
# Backend (migrate + seed + serve on 3000), frontend preview (3001), nginx (8080).
|
||||
CMD ["sh", "-c", "(cd /app/backend && npm run start:production) & (cd /app/frontend && npm run start) & nginx -g 'daemon off;'"]
|
||||
|
||||
12
backend/.env
12
backend/.env
@ -1 +1,13 @@
|
||||
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
|
||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@ -1,7 +0,0 @@
|
||||
const path = require('path');
|
||||
module.exports = {
|
||||
"config": path.resolve("src", "db", "db.config.js"),
|
||||
"models-path": path.resolve("src", "db", "models"),
|
||||
"seeders-path": path.resolve("src", "db", "seeders"),
|
||||
"migrations-path": path.resolve("src", "db", "migrations")
|
||||
};
|
||||
86
backend/docs/academic_years.md
Normal file
86
backend/docs/academic_years.md
Normal file
@ -0,0 +1,86 @@
|
||||
# Academic Years Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`academic_years` is the per-organization roster of academic year periods. It is a generic-CRUD
|
||||
slice assembled from the shared factories; the backend is the source of truth for these records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/academic_years.ts` — `createCrudRouter(controller, { permission: 'academic_years' })`.
|
||||
- Controller: `src/api/controllers/academic_years.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/academic_years.ts` — `createCrudService(DbApi, { notFoundCode: 'academic_yearsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/academic_years.ts` (`Academic_yearsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/academic_years.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/academic_years`, JWT + `${METHOD}_ACADEMIC_YEARS`
|
||||
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**), 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`, `start_date`, `end_date`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('academic_years')`, deriving
|
||||
`READ_ACADEMIC_YEARS` / `CREATE_ACADEMIC_YEARS` / `UPDATE_ACADEMIC_YEARS` /
|
||||
`DELETE_ACADEMIC_YEARS` 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` (TEXT, nullable).
|
||||
- `start_date`, `end_date` — DATE, nullable.
|
||||
- `current` — BOOLEAN, `allowNull: false`, default `false`.
|
||||
- `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
|
||||
`organization` in a single `Promise.all`.
|
||||
|
||||
List filters (`AcademicYearsFilter`): `id`, `name`, `calendarStart`+`calendarEnd` (matches rows
|
||||
whose `start_date` or `end_date` falls between the two), `start_dateRange`, `end_dateRange`,
|
||||
`active`, `current`, `organization` (`|`-separated ids), `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: `AcademicYearsFilter` 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: `classes`, `timetables`,
|
||||
`fee_plans`, `organizations`, `permissions.md`.
|
||||
91
backend/docs/assessment_results.md
Normal file
91
backend/docs/assessment_results.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Assessment Results Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`assessment_results` is the per-organization join between an assessment and a student, holding
|
||||
that student's score, letter grade, and remarks for the assessment. It is a generic-CRUD slice
|
||||
assembled from the shared factories; the backend is the source of truth for these records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/assessment_results.ts` — `createCrudRouter(controller, { permission: 'assessment_results' })`.
|
||||
- Controller: `src/api/controllers/assessment_results.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/assessment_results.ts` — `createCrudService(DbApi, { notFoundCode: 'assessment_resultsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/assessment_results.ts` (`Assessment_resultsDBApi`) —
|
||||
entity-specific `create`/`bulkImport`/`update`/`findBy`/`findAll`;
|
||||
`remove`/`deleteByIds`/`findAllAutocomplete` delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/assessment_results.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/assessment_results`, JWT +
|
||||
`${METHOD}_ASSESSMENT_RESULTS` 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**), 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
|
||||
`grade_letter`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `remarks`, `score`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('assessment_results')`,
|
||||
deriving `READ_ASSESSMENT_RESULTS` / `CREATE_ASSESSMENT_RESULTS` / `UPDATE_ASSESSMENT_RESULTS`
|
||||
/ `DELETE_ASSESSMENT_RESULTS` 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), `remarks` (TEXT, nullable).
|
||||
- `score` — DECIMAL (nullable).
|
||||
- `grade_letter` — ENUM `A` | `B` | `C` | `D` | `E` | `F` | `P` | `N`.
|
||||
- `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`.
|
||||
|
||||
List filters (`AssessmentResultsFilter`): `id`, `remarks`, `scoreRange`, `active`,
|
||||
`grade_letter`, `assessment` (id or assessment `name`, `|`-separated, applied as an `include`
|
||||
where-clause), `student` (id or `student_number`, `|`-separated, applied as an `include`
|
||||
where-clause), `organization` (`|`-separated ids), `createdAtRange`, plus `field`/`sort`
|
||||
ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`update` set the `assessment`, `student`, and `organization` associations from the
|
||||
ids in the request body.
|
||||
- `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: `AssessmentResultsFilter` 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: `assessments`, `students`,
|
||||
`organizations`, `permissions.md`.
|
||||
96
backend/docs/assessments.md
Normal file
96
backend/docs/assessments.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Assessments Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`assessments` is the per-organization catalog of student assessments (quizzes, homework, exams,
|
||||
etc.) attached to a class subject. It is a generic-CRUD slice assembled from the shared factories;
|
||||
the backend is the source of truth for these records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/assessments.ts` — `createCrudRouter(controller, { permission: 'assessments' })`.
|
||||
- Controller: `src/api/controllers/assessments.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/assessments.ts` — `createCrudService(DbApi, { notFoundCode: 'assessmentsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/assessments.ts` (`AssessmentsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/assessments.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/assessments`, JWT + `${METHOD}_ASSESSMENTS`
|
||||
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**), 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`, `instructions`, `max_score`, `assigned_at`, `due_at`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('assessments')`, deriving
|
||||
`READ_ASSESSMENTS` / `CREATE_ASSESSMENTS` / `UPDATE_ASSESSMENTS` / `DELETE_ASSESSMENTS` 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`, `instructions` (TEXT, nullable).
|
||||
- `assessment_type` — ENUM `quiz` | `homework` | `project` | `midterm` | `final` | `other`.
|
||||
- `status` — ENUM `draft` | `published` | `closed`.
|
||||
- `assigned_at`, `due_at` — DATE, nullable.
|
||||
- `max_score` — DECIMAL (nullable).
|
||||
- `importHash` (unique), `organizationId`, `class_subjectId`, `createdById`, `updatedById`,
|
||||
timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, class_subject (class_subjects), createdBy/updatedBy
|
||||
(users); `hasMany` `assessment_results_assessment` (assessment_results); `hasMany` file as
|
||||
`attachments` (scoped relation). `findBy`/`GET /:id` eager-load `assessment_results_assessment`,
|
||||
`organization`, `class_subject`, and `attachments` in a single `Promise.all`.
|
||||
|
||||
List filters (`AssessmentsFilter`): `id`, `name`, `instructions`, `calendarStart`+`calendarEnd`
|
||||
(matches rows whose `assigned_at` or `due_at` falls between the two), `assigned_atRange`,
|
||||
`due_atRange`, `max_scoreRange`, `active`, `assessment_type`, `status`, `class_subject` (id or
|
||||
`status` text, `|`-separated, applied as an `include` where-clause), `organization`
|
||||
(`|`-separated ids), `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: `AssessmentsFilter` accepts an `active` flag the model has no column for; it is
|
||||
currently inert (kept for source accuracy).
|
||||
- Note: the `class_subject` filter matches against the related class_subjects' `status` field
|
||||
for the text branch (kept for source accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `assessment_results`,
|
||||
`class_subjects`, `organizations`, `file.md`, `permissions.md`.
|
||||
98
backend/docs/attendance_records.md
Normal file
98
backend/docs/attendance_records.md
Normal file
@ -0,0 +1,98 @@
|
||||
# Attendance Records Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`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.
|
||||
|
||||
This is the generic student/session attendance entity; staff attendance is the separate
|
||||
`staff_attendance` slice (documented elsewhere).
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/attendance_records.ts` — `createCrudRouter(controller, { permission: 'attendance_records' })`.
|
||||
- Controller: `src/api/controllers/attendance_records.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/attendance_records.ts` — `createCrudService(DbApi, { notFoundCode: 'attendance_recordsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/attendance_records.ts` (`Attendance_recordsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/attendance_records.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/utils.ts` (`Utils.uuid`/`Utils.ilike`), `shared/constants/database.ts`
|
||||
(`BULK_IMPORT_TIMESTAMP_STEP_MS`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/attendance_records`, JWT +
|
||||
`${METHOD}_ATTENDANCE_RECORDS` permission, all `200`) — see `backend-architecture.md` for the
|
||||
shared 9-endpoint 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**), 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
|
||||
`status`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `remarks`, `minutes_late`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('attendance_records')`,
|
||||
deriving `READ_ATTENDANCE_RECORDS` / `CREATE_ATTENDANCE_RECORDS` /
|
||||
`UPDATE_ATTENDANCE_RECORDS` / `DELETE_ATTENDANCE_RECORDS` 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).
|
||||
- `status` — ENUM `present` | `absent` | `late` | `excused`.
|
||||
- `minutes_late` — INTEGER.
|
||||
- `remarks` — TEXT.
|
||||
- `importHash` (STRING(255), unique), `organizationId`, `attendance_sessionId`, `studentId`,
|
||||
`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,
|
||||
attendance_session, and student in a single `Promise.all`.
|
||||
|
||||
List filters (`AttendanceRecordsFilter`): `id`, `remarks` (iLike), `minutes_lateRange`,
|
||||
`status`, `attendance_session` (id or `session_type`, `|`-separated), `student` (id or
|
||||
`student_number`), `organization`, `createdAtRange`, plus `field`/`sort` ordering and
|
||||
`limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`bulkImport`/`update` set associations via the Sequelize `set*` mixins (no file
|
||||
relations on this entity).
|
||||
- `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: `AttendanceRecordsFilter` accepts an `active` flag the model has no column for; it is
|
||||
applied to `where.active` but, with no such column, is currently inert (kept for source
|
||||
accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_sessions`,
|
||||
`students`, `permissions.md`.
|
||||
97
backend/docs/attendance_sessions.md
Normal file
97
backend/docs/attendance_sessions.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Attendance Sessions Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`attendance_sessions` records an attendance-taking occasion (a homeroom, subject, exam, or
|
||||
event sitting) against a class. It is a generic-CRUD slice assembled from the shared factories;
|
||||
each session is the parent of the individual `attendance_records` entered for it.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/attendance_sessions.ts` — `createCrudRouter(controller, { permission: 'attendance_sessions' })`.
|
||||
- Controller: `src/api/controllers/attendance_sessions.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/attendance_sessions.ts` — `createCrudService(DbApi, { notFoundCode: 'attendance_sessionsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/attendance_sessions.ts` (`Attendance_sessionsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/attendance_sessions.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/utils.ts` (`Utils.uuid`/`Utils.ilike`), `shared/constants/database.ts`
|
||||
(`BULK_IMPORT_TIMESTAMP_STEP_MS`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/attendance_sessions`, JWT +
|
||||
`${METHOD}_ATTENDANCE_SESSIONS` permission, all `200`) — see `backend-architecture.md` for the
|
||||
shared 9-endpoint 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**), 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
|
||||
`session_type`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `notes`, `session_date`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('attendance_sessions')`,
|
||||
deriving `READ_ATTENDANCE_SESSIONS` / `CREATE_ATTENDANCE_SESSIONS` /
|
||||
`UPDATE_ATTENDANCE_SESSIONS` / `DELETE_ATTENDANCE_SESSIONS` 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).
|
||||
- `session_date` — DATE.
|
||||
- `session_type` — ENUM `homeroom` | `subject` | `exam` | `event`.
|
||||
- `notes` — TEXT.
|
||||
- `importHash` (STRING(255), unique), `organizationId`, `campusId`, `classId`,
|
||||
`class_subjectId`, `taken_byId`, `createdById`, `updatedById`, timestamps (all UUID FKs
|
||||
nullable).
|
||||
|
||||
Associations: `belongsTo` organization, campus, class (`classes`, as `class`),
|
||||
class_subject (`class_subjects`), taken_by (`staff`), createdBy/updatedBy (users); `hasMany`
|
||||
`attendance_records` as `attendance_records_attendance_session`. `findBy`/`GET /:id` eager-load
|
||||
`attendance_records_attendance_session`, organization, campus, class, class_subject, and
|
||||
taken_by in a single `Promise.all`.
|
||||
|
||||
List filters (`AttendanceSessionsFilter`): `id`, `notes` (iLike), `session_dateRange`,
|
||||
`session_type`, `campus` (id or name, `|`-separated), `class` (id or name), `class_subject`
|
||||
(id or status), `taken_by` (id or `employee_number`), `organization`, `createdAtRange`, plus
|
||||
`field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`bulkImport`/`update` set associations via the Sequelize `set*` mixins (no file
|
||||
relations on this entity).
|
||||
- `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: `AttendanceSessionsFilter` accepts an `active` flag the model has no column for; it is
|
||||
applied to `where.active` but, with no such column, is currently inert (kept for source
|
||||
accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `attendance_records`,
|
||||
`classes`, `class_subjects`, `campuses`, `staff`, `students`, `permissions.md`.
|
||||
@ -1,51 +1,183 @@
|
||||
# Auth Profile Contract
|
||||
# Auth Profile
|
||||
|
||||
## Purpose
|
||||
|
||||
`GET /api/auth/me` is the backend-owned current user profile contract for the product frontend.
|
||||
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
|
||||
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`.
|
||||
|
||||
The endpoint must not expose passwords, verification tokens, reset tokens, or raw Sequelize model objects.
|
||||
The profile response must not expose passwords, verification tokens, reset
|
||||
tokens, or raw Sequelize model objects.
|
||||
|
||||
The auth transport hardening is documented in `backend/docs/cookie-auth.md`.
|
||||
## Slice Files (by layer)
|
||||
|
||||
## Response Shape
|
||||
- Route: `src/routes/auth.ts` (thin wiring; mounted at `/api/auth` in
|
||||
`src/index.ts`). `/me`, `/password-update`, `/send-email-address-verification-email`,
|
||||
and `/profile` are guarded by `passport.authenticate('jwt', { session: false })`.
|
||||
- Controller: `src/api/controllers/auth.controller.ts` (custom — not the CRUD
|
||||
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).
|
||||
- Cookie helpers: `src/auth/cookies.ts` (used for the session transport;
|
||||
see `cookie-auth.md`).
|
||||
- Permission middleware: `src/middlewares/check-permissions.ts`
|
||||
(`checkPermissions`, `checkCrudPermissions`).
|
||||
- Origin middleware: `src/middlewares/csrf-origin.ts` (see `cookie-auth.md`).
|
||||
- Repositories (DAL): `src/db/api/users.ts` (`UsersDBApi`),
|
||||
`src/db/api/auth_refresh_tokens.ts` (`AuthRefreshTokensDBApi`),
|
||||
`src/db/api/roles.ts` (`RolesDBApi`, used by the permission middleware).
|
||||
- Models: `src/db/models/users.ts`, `src/db/models/auth_refresh_tokens.ts`,
|
||||
plus the `roles`, `permissions`, `organizations`, `staff`, and `campuses`
|
||||
models joined for the profile.
|
||||
- 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`,
|
||||
`ValidationError`), `services/email/*` (verification / reset / invitation
|
||||
emails).
|
||||
|
||||
The endpoint returns:
|
||||
## API
|
||||
|
||||
- `id`
|
||||
- `email`
|
||||
- `firstName`
|
||||
- `lastName`
|
||||
- `phoneNumber`
|
||||
- `organizationsId`
|
||||
- `organizations`
|
||||
- `app_role`
|
||||
- `productRole`
|
||||
- `staffProfile`
|
||||
- `campus`
|
||||
- `campusId`
|
||||
- `permissions`
|
||||
Base path `/api/auth` (mounted in `src/index.ts`).
|
||||
|
||||
`productRole` is derived server-side from generated backend roles first, then staff type, then the default teacher role.
|
||||
Profile / account endpoints:
|
||||
|
||||
## Constants
|
||||
- `GET /api/auth/me` (JWT-authenticated) -> `200` the current user profile
|
||||
payload (see Data Contract). Returns `ForbiddenError` when there is no
|
||||
current user.
|
||||
- `POST /api/auth/signup` -> `200` the current user profile. Body:
|
||||
`{ email, password, organizationId }`. Creates a session and sets cookies.
|
||||
- `PUT /api/auth/profile` (JWT-authenticated) -> `200` `true`. Body:
|
||||
`{ profile }` (passed to `UsersDBApi.update`).
|
||||
- `PUT /api/auth/password-update` (JWT-authenticated) -> `200` the updated
|
||||
user. Body: `{ currentPassword, newPassword }`.
|
||||
- `PUT /api/auth/password-reset` -> `200` the updated user. Body:
|
||||
`{ token, password }`.
|
||||
- `PUT /api/auth/verify-email` -> `200` result of marking the email verified.
|
||||
Body: `{ token }`.
|
||||
- `POST /api/auth/send-password-reset-email` -> `200` `true`. Body: `{ email }`.
|
||||
The reset link host is derived from the request referer.
|
||||
- `POST /api/auth/send-email-address-verification-email` (JWT-authenticated)
|
||||
-> `200` `true`. Sends a verification email to the current user's address.
|
||||
- `GET /api/auth/email-configured` -> `200` boolean (`EmailSender.isConfigured`).
|
||||
|
||||
Role names and mappings live in `backend/src/constants/roles.js`.
|
||||
Sign-in (covered in detail in `cookie-auth.md`):
|
||||
|
||||
Do not duplicate generated-role to product-role mapping in frontend code.
|
||||
- `POST /api/auth/signin/local` -> `200` the current user profile.
|
||||
|
||||
## Security Rules
|
||||
OAuth endpoints:
|
||||
|
||||
- JWT validation remains handled by Passport.
|
||||
- The target browser auth transport is a backend-owned HttpOnly cookie.
|
||||
- Auth tokens must not be returned to the product frontend in response bodies or redirect URLs.
|
||||
- Missing or invalid current users return the centralized forbidden error.
|
||||
- The response is formatted by `AuthService.currentUserProfile`.
|
||||
- Secrets are read from `backend/.env` or process environment only.
|
||||
- `backend/.env` is ignored by git; repository access should still be treated carefully because local deployment values can exist in the working copy.
|
||||
- `GET /api/auth/signin/google` -> redirects to Google
|
||||
(`passport.authenticate('google', { scope: ['profile', 'email'], state })`).
|
||||
- `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`.
|
||||
|
||||
## Known Gaps
|
||||
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`.
|
||||
|
||||
- Product roles still need a persistent backend migration or a documented server-owned mapping decision.
|
||||
- Staff profile creation and update flows are not complete.
|
||||
- Tenant and campus isolation tests still need to be added.
|
||||
## Access Rules
|
||||
|
||||
- JWT routes are protected by `passport.authenticate('jwt', { session: false })`,
|
||||
which extracts the access token from the HttpOnly access cookie
|
||||
(`cookies.extractAccessCookie`) and loads the user by email. A disabled user
|
||||
is rejected by the strategy (`done(new Error(...))`).
|
||||
- Permission enforcement (`src/middlewares/check-permissions.ts`):
|
||||
- `checkPermissions(permission)` allows the request if any of:
|
||||
1. self-access bypass — `currentUser.id` equals `req.params.id` or
|
||||
`req.body.id`;
|
||||
2. 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.
|
||||
- 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
|
||||
method maps `POST->CREATE`, `GET->READ`, `PUT->UPDATE`, `PATCH->UPDATE`,
|
||||
`DELETE->DELETE` and `ENTITY` is `name.toUpperCase()`. It then delegates to
|
||||
`checkPermissions`. This middleware is applied per generic-CRUD router via
|
||||
`router.use(permissions.checkCrudPermissions(permission))` in
|
||||
`src/api/http/crud-router.ts`; the auth routes themselves do not use it.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- The profile is loaded for the authenticated user only
|
||||
(`UsersDBApi.findProfileById(currentUser.id)`), so it reflects that user's
|
||||
own organization, role, staff profile, and campus.
|
||||
- `signup` accepts an `organizationId` and assigns it to the created user.
|
||||
- Tenant filtering for other entities is enforced elsewhere (CRUD repositories
|
||||
scope by `currentUser.organizationId`); the auth profile endpoints do not
|
||||
add tenant filtering beyond loading the current user.
|
||||
|
||||
## Data Contract
|
||||
|
||||
`AuthService.currentUserProfile` returns (built from `findProfileById`):
|
||||
|
||||
- `id`, `email`, `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`)
|
||||
- `staffProfile` — `StaffProfileDto`
|
||||
`{ id, employee_number, job_title, staff_type, status, organizationId,
|
||||
campusId, userId }` or `null` (first row of `staff_user`)
|
||||
- `campus` — `CampusDto` `{ id, name, code }` or `null`
|
||||
- `campusId` — the campus DTO id, else the staff profile `campusId`, else `null`
|
||||
- `permissions` — de-duplicated string names from the role's permissions plus
|
||||
the user's `custom_permissions`
|
||||
|
||||
Note: the profile payload does not include a `phoneNumber` field
|
||||
(`findProfileById` does not select it and `currentUserProfile` does not return
|
||||
it).
|
||||
|
||||
`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`.
|
||||
|
||||
Signup / signin behavior (`src/services/auth.ts`):
|
||||
|
||||
- `signin` throws `ValidationError` for `auth.userNotFound`, `auth.userDisabled`,
|
||||
`auth.wrongPassword`, or `auth.userNotVerified`. When email is not configured
|
||||
(`EmailSender.isConfigured` is false), `emailVerified` is treated as true.
|
||||
- `signup` rehashes the password with `bcrypt` (`config.bcrypt.saltRounds`).
|
||||
An existing disabled user raises `auth.userDisabled`; an existing enabled user
|
||||
has its password updated; a new user is created via `UsersDBApi.createFromAuth`
|
||||
(first name defaults to the email local-part, default role from
|
||||
`config.roles.user`). A verification email is sent when email is configured.
|
||||
- `passwordUpdate` requires a logged-in user, verifies the current password,
|
||||
rejects reuse of the same password (`auth.passwordUpdate.samePassword`), and
|
||||
stores the new bcrypt hash.
|
||||
- `passwordReset` / `verifyEmail` look up the user by a non-expired
|
||||
`passwordResetToken` / `emailVerificationToken` and raise the matching
|
||||
`ValidationError` on an invalid token. Tokens are random hex of
|
||||
`EMAIL_ACTION_TOKEN_BYTES` with TTL `EMAIL_ACTION_TOKEN_TTL_MS`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- Profile loads use a single trimmed eager query (`UsersDBApi.findProfileById`)
|
||||
selecting only the columns and relations the DTO reads.
|
||||
- Auth tokens are never returned in response bodies or redirect URLs; OAuth
|
||||
callbacks redirect to `config.uiUrl` with cookies only.
|
||||
- `updateProfile` runs inside a Sequelize transaction.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no auth unit/e2e test under `backend/src`).
|
||||
|
||||
## Related
|
||||
|
||||
- Cookie session transport: `backend/docs/cookie-auth.md`.
|
||||
- Frontend: `frontend/docs/auth-integration.md`.
|
||||
- Role / product-role constants: `src/shared/constants/roles.ts`.
|
||||
|
||||
184
backend/docs/backend-architecture.md
Normal file
184
backend/docs/backend-architecture.md
Normal file
@ -0,0 +1,184 @@
|
||||
# Backend Architecture
|
||||
|
||||
The backend uses a three-layer architecture, mirroring the frontend
|
||||
(`frontend/docs/frontend-architecture.md`):
|
||||
|
||||
- API layer (HTTP)
|
||||
- Business logic layer (BLL)
|
||||
- Data access layer (DAL)
|
||||
|
||||
The goal is to keep routes thin, keep business rules testable and free of HTTP,
|
||||
and keep all persistence in one place.
|
||||
|
||||
## Layer 1: API
|
||||
|
||||
Location:
|
||||
|
||||
- `src/routes/` — thin route wiring: `path → middleware → wrapAsync(controller)`.
|
||||
- `src/api/controllers/` — one `<feature>.controller.ts` per feature; exported
|
||||
async handler functions `(req, res) => …`.
|
||||
- `src/api/http/` — request helpers (`wrapAsync`, `queryStr`, `queryNum`,
|
||||
`paramStr`).
|
||||
- `src/middlewares/` — `authenticate` (passport), `checkPermissions`,
|
||||
`csrf-origin`, `error-handler`, `upload`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Parse and validate the HTTP request (query, params, body, cookies, uploads).
|
||||
- Run middleware (auth, permissions, CSRF).
|
||||
- Call exactly one BLL service and shape the HTTP response (status + body).
|
||||
- Own multipart/upload parsing; pass parsed data (e.g. a file buffer) to the BLL.
|
||||
|
||||
The API layer must not:
|
||||
|
||||
- Import the DAL (`@/db/api/*`, `@/db/models/*`) — it goes through a service.
|
||||
- Contain tenant/role/permission/workflow rules or DTO mapping.
|
||||
- Run database queries.
|
||||
|
||||
## Layer 2: Business Logic (BLL)
|
||||
|
||||
Location:
|
||||
|
||||
- `src/services/` — one `<feature>.ts` (class with static methods) per feature,
|
||||
plus per-feature mappers/validators/helpers as needed. Infra BLL lives in
|
||||
`src/services/email/`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Own workflows, transactions, and coordination across repositories.
|
||||
- Apply tenant, role, campus, and permission rules.
|
||||
- Map DB records to response DTOs; validate and normalize inputs.
|
||||
- Accept typed inputs and return typed values/DTOs.
|
||||
|
||||
The BLL must not:
|
||||
|
||||
- Touch Express `req`/`res` or import `express`/middleware. (Two legacy
|
||||
exceptions remain — `services/file.ts` streaming and `services/auth.ts` session
|
||||
IP/UA + cookies — tracked by the boundary test and to be revisited.)
|
||||
- Import the API layer.
|
||||
- Render HTTP responses.
|
||||
|
||||
## Layer 3: Data Access (DAL)
|
||||
|
||||
Location:
|
||||
|
||||
- `src/db/api/` — one `*DBApi` class per entity (the repository layer).
|
||||
- `src/db/models/` — Sequelize models.
|
||||
- `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`.
|
||||
- `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`,
|
||||
`CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Own all Sequelize queries and schema.
|
||||
- Return records/plain data to the BLL.
|
||||
|
||||
The DAL must not:
|
||||
|
||||
- Import the API layer or the BLL. (One legacy exception: `db/api/file.ts`
|
||||
imports `services/file` for GCloud blob deletion — tracked, to be inverted.)
|
||||
- Apply business rules or touch HTTP.
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
Location: `src/shared/` (+ ambient types in `src/types/`).
|
||||
|
||||
- `shared/constants/` — all constants/config values (was `src/constants`).
|
||||
- `shared/config/` — env-driven runtime config (`index.ts` + `load-env.ts`).
|
||||
- `shared/errors/` — `AppError` and subclasses.
|
||||
- `shared/notifications/` — i18n message catalog + helpers.
|
||||
- `shared/logger.ts`, `shared/csv.ts`, `shared/jwt.ts`.
|
||||
- `shared/architecture/` — the import-boundary test.
|
||||
|
||||
Cross-cutting code depends on no layer and may be imported by any layer.
|
||||
|
||||
## Import direction
|
||||
|
||||
Allowed:
|
||||
|
||||
```text
|
||||
Route → Controller → Service (BLL) → Repository/Model (DAL) → DB
|
||||
```
|
||||
|
||||
`shared/*` may be imported by any layer. Disallowed:
|
||||
|
||||
```text
|
||||
API (routes/controllers) → DAL (skip the BLL)
|
||||
BLL (services) → Express / API
|
||||
DAL (db) → BLL / API
|
||||
shared/* → any layer
|
||||
```
|
||||
|
||||
## Feature structure
|
||||
|
||||
Layer-first directories, one file per feature inside each layer (only create what
|
||||
a feature needs):
|
||||
|
||||
```text
|
||||
src/routes/<feature>.ts
|
||||
src/api/controllers/<feature>.controller.ts
|
||||
src/services/<feature>.ts (+ mappers/validators when needed)
|
||||
src/db/api/<feature>.ts (repository)
|
||||
src/db/models/<feature>.ts
|
||||
src/shared/constants/<feature>.ts
|
||||
```
|
||||
|
||||
## Module authoring (shared factories & helpers)
|
||||
|
||||
Most modules are assembled from shared factories/helpers — keep them that way.
|
||||
|
||||
- **Generic CRUD entity** = three one-line config files:
|
||||
- `src/services/<e>.ts` → `export default createCrudService(EntityDBApi, { notFoundCode });`
|
||||
- `src/api/controllers/<e>.controller.ts` → `export default createCrudController(service, { csvFields });`
|
||||
- `src/routes/<e>.ts` → `export default createCrudRouter(controller, { permission });`
|
||||
|
||||
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.
|
||||
- **Repository (DAL)** = entity-specific `create`/`update`/`bulkImport`/`findBy`/
|
||||
`findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to
|
||||
`db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
|
||||
`autocompleteByField`).
|
||||
- **Feature service (BLL)** = reuse shared helpers: tenant/role access in
|
||||
`services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`,
|
||||
`hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`,
|
||||
`campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …);
|
||||
validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`,
|
||||
`requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts`
|
||||
(`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`;
|
||||
`isRecord` from `shared/object.ts`.
|
||||
- `getOrganizationIdOrGlobal(user)`: returns `null` for global access users
|
||||
(bypassing org filter) or the user's org ID; throws `ForbiddenError` if neither.
|
||||
- `hasGlobalAccess(user)`: returns `true` when `app_role.globalAccess === true`.
|
||||
- `assertAuthenticatedTenantUser(user)`: allows global access users even without
|
||||
an organization (useful for platform-level admins).
|
||||
|
||||
## Error handling
|
||||
|
||||
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.
|
||||
|
||||
## Enforcement & verification
|
||||
|
||||
- `src/shared/architecture/import-boundaries.test.ts` enforces the import
|
||||
direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL
|
||||
edge cases and the one DAL→BLL leak are capped by ceilings that must not grow.
|
||||
- ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the
|
||||
already-clean invariants at lint time (API→DAL, model/DAL/shared purity).
|
||||
- `npm run typecheck`, `npm run lint`, `npm test` are the verification gates;
|
||||
`npm test` runs the Node test runner via `tsx` (error-handler + boundary tests).
|
||||
|
||||
## Known remaining items
|
||||
|
||||
- `services/file.ts` and `services/auth.ts` still depend on `req`/`res` (file
|
||||
streaming; session IP/UA + cookies). To be revisited with the upload subsystem.
|
||||
- `db/api/file.ts` → `services/file` (GCloud delete) is a DAL→BLL leak to invert
|
||||
(the BLL should orchestrate blob + row deletion).
|
||||
- `src/index.ts` remains the composition root + entry; an `app/server.ts` split is
|
||||
optional and deferred (deploy runs `dist/index.js`).
|
||||
- Repositories still hand-roll the `findAll` filter→`where` building per entity; a
|
||||
declarative where-builder could dedup it, deferred until the data platform
|
||||
stabilizes (higher-risk Sequelize typing).
|
||||
@ -1,65 +1,54 @@
|
||||
# Campus Attendance Backend
|
||||
|
||||
## Purpose
|
||||
The campus attendance slice owns campus attendance system links (`campus_attendance_config`) and manually entered daily campus attendance summaries (`campus_attendance_summaries`), both scoped per organization. The backend is the source of truth for these records. The UI works with daily aggregate totals, not student-level attendance sessions; student-level data remains in the separate generated `attendance_sessions` / `attendance_records` models and is not handled here.
|
||||
|
||||
The campus attendance API stores campus attendance system links and manually entered daily campus attendance summaries.
|
||||
|
||||
The current CampusAttendance UI uses daily aggregate totals, not student-level attendance sessions. Student-level attendance remains in the existing generated `attendance_sessions` and `attendance_records` models.
|
||||
|
||||
## Data Model
|
||||
|
||||
The module uses:
|
||||
|
||||
- `campus_attendance_config`
|
||||
- `campus_attendance_summaries`
|
||||
|
||||
Both tables include:
|
||||
|
||||
- `organizationId` for tenant ownership.
|
||||
- `campus_key` for the approved UI campus keys: `tigers`, `gators`, `hawks`, `owls`, `wildcats`, `grizzlies`.
|
||||
- nullable `campusId` for future linkage to persisted campus rows.
|
||||
- audit fields and soft delete timestamps.
|
||||
## Slice Files (by layer)
|
||||
- Route: `src/routes/campus_attendance.ts` (thin wiring; `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`). Mounted at `/api/campus_attendance` behind the `authenticated` middleware in `src/index.ts`.
|
||||
- Controller: `src/api/controllers/campus_attendance.controller.ts` (custom — `listConfigs`, `upsertConfig`, `listSummaries`, `upsertSummary`).
|
||||
- Service (BLL): `src/services/campus_attendance.ts` (+ `src/services/campus_attendance.types.ts`). Contains its own validation, scope resolution, and DTO mappers.
|
||||
- Repository (DAL): queries run through `db.campus_attendance_config` and `db.campus_attendance_summaries` inside the service (no separate `db/api` file).
|
||||
- Models: `src/db/models/campus_attendance_config.ts`, `src/db/models/campus_attendance_summaries.ts`.
|
||||
- Shared used: `db/with-transaction.ts`, `services/shared/access.ts`, `services/shared/validate.ts` (`clampLimit`, `requiredIsoDate`), `shared/constants/campus-attendance.ts` (role lists, limits, `normalizeCampusKey`, `getProductRole`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
|
||||
- `GET /api/campus_attendance/configs`
|
||||
- `GET /api/campus_attendance/configs?campusKey=<campusKey>`
|
||||
- `PUT /api/campus_attendance/configs/:campusKey`
|
||||
- `GET /api/campus_attendance/summaries`
|
||||
- `GET /api/campus_attendance/summaries?campusKey=<campusKey>&startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>`
|
||||
- `PUT /api/campus_attendance/summaries/:campusKey/:date`
|
||||
- `GET /api/campus_attendance/configs` -> `200` `{ rows, count }`. `rows` are config DTOs. Optional query `campusKey`; supports `limit` and `page` via `resolvePagination`.
|
||||
- `PUT /api/campus_attendance/configs/:campusKey` -> `200` the upserted config DTO. Body is `req.body.data` with optional `attendance_link`.
|
||||
- `GET /api/campus_attendance/summaries` -> `200` `{ rows, count }`. `rows` are summary DTOs. Optional query `campusKey`, `startDate`, `endDate` (ISO `YYYY-MM-DD`), `limit`.
|
||||
- `PUT /api/campus_attendance/summaries/:campusKey/:date` -> `200` the upserted summary DTO. `:date` must be an ISO date. Body is `req.body.data`.
|
||||
|
||||
Config DTO fields: `id`, `campus_key`, `attendance_link`, `updated_by_label`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
||||
|
||||
Summary DTO fields: `id`, `campus_key`, `date` (from `attendance_date`), `total_enrolled`, `total_present`, `total_absent`, `total_tardy`, `attendance_percentage` (number), `recorded_by_label`, `notes`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
||||
|
||||
## 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`.
|
||||
- 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`).
|
||||
|
||||
- Tenant-wide leadership roles can read all campus config and summary records.
|
||||
- Campus-scoped users can read their own campus when their backend campus name/code maps to an approved `campus_key`.
|
||||
- Attendance manager roles can update links and daily summaries.
|
||||
- The frontend does not send organization, campus UUID, creator, updater, or recorded-by labels. The backend derives them from the authenticated user.
|
||||
## Tenant Scope
|
||||
- Every read and write filters by `organizationId: requireOrganizationId(currentUser)`.
|
||||
- `campusScope` resolves the `campus_key` filter: a requested `campusKey` is access-checked then applied; tenant-wide roles with no requested key see all campus keys (no `campus_key` filter); other users are restricted to their own derived `campus_key`, and users with no derivable campus key are rejected with `ForbiddenError`.
|
||||
- On upsert, the existing-row lookup keys on `organizationId` + `campus_key` (config) or `organizationId` + `campus_key` + `attendance_date` (summary).
|
||||
|
||||
## Data Contract
|
||||
- Config mutation input (`ConfigInput`): optional `attendance_link` (stored as trimmed text or `null`).
|
||||
- Summary mutation input (`SummaryInput`): `total_enrolled`, `total_present`, `total_absent` are required non-negative integers; `total_tardy` is optional (defaults to `0`); `notes` optional text. Validation requires `total_enrolled > 0` and that present/absent/tardy each do not exceed enrolled.
|
||||
- `attendance_percentage` is computed by the backend as `(total_present / total_enrolled) * 100`, stored as `DECIMAL(5,2)` and returned as a number.
|
||||
- Models: both tables carry `organizationId` (not null), nullable `campusId`, `createdById` (not null), nullable `updatedById`, `paranoid` soft deletes, and `belongsTo` associations to `organizations`, `campuses`, and `users` (createdBy, updatedBy). `campus_attendance_config` adds `attendance_link` and `updated_by_label`; `campus_attendance_summaries` adds `attendance_date` (DATEONLY), the four totals, `attendance_percentage`, `recorded_by_label`, and `notes`.
|
||||
- List pagination: configs use `resolvePagination(limit, page)`; summaries use `clampLimit(limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT=120, CAMPUS_ATTENDANCE_MAX_LIMIT=366)` and have no offset paging.
|
||||
|
||||
Config mutation fields:
|
||||
## Behavior / Notes
|
||||
- Both upserts run inside `withTransaction`: find existing row, then `update` it or `create` a new one (setting `createdById` on create).
|
||||
- Config list orders by `campus_key asc`; summary list orders by `attendance_date desc`, then `campus_key asc`.
|
||||
- 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`.
|
||||
|
||||
- `attendance_link`
|
||||
## Tests
|
||||
None yet (no `*.test.ts` under `backend/src` references this slice).
|
||||
|
||||
Summary mutation fields:
|
||||
|
||||
- `total_enrolled`
|
||||
- `total_present`
|
||||
- `total_absent`
|
||||
- `total_tardy`
|
||||
- `notes`
|
||||
|
||||
The backend calculates `attendance_percentage` from `total_present / total_enrolled`. The frontend displays the backend-calculated value.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/constants/campus-attendance.js`
|
||||
- `backend/src/db/models/campus_attendance_config.js`
|
||||
- `backend/src/db/models/campus_attendance_summaries.js`
|
||||
- `backend/src/db/migrations/20260608006000-create-campus-attendance-config.js`
|
||||
- `backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js`
|
||||
- `backend/src/services/campus_attendance.js`
|
||||
- `backend/src/routes/campus_attendance.js`
|
||||
## Related
|
||||
- Frontend: `frontend/docs/campus-attendance-integration.md`.
|
||||
|
||||
@ -1,46 +1,70 @@
|
||||
# Campus Catalog
|
||||
# Campus Catalog Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
The database is the source of truth for campus records. Runtime frontend code must not ship campus rows as constants.
|
||||
This slice is the public, read-only surface for campus records: it returns the active campuses with
|
||||
their branding tokens so the frontend can render them without shipping campus rows as runtime
|
||||
constants. The database is the source of truth for campuses. Authenticated campus create/update/delete
|
||||
lives in a separate slice (see "campuses CRUD" below); this slice does not write.
|
||||
|
||||
## Backend Contract
|
||||
## Slice Files (by layer)
|
||||
|
||||
Public read-only campus catalog:
|
||||
- Route: `src/routes/public_campuses.ts` (thin wiring; `GET /`).
|
||||
- Controller: `src/api/controllers/public_campuses.controller.ts` (custom — not the CRUD factory).
|
||||
- Service (BLL): `src/services/campus_catalog.ts`.
|
||||
- Repository (DAL): the catalog service queries `db.campuses.findAll` directly (it does not go
|
||||
through `src/db/api/campuses.ts`; that repository serves the authenticated campuses CRUD slice).
|
||||
- Model: `src/db/models/campuses.ts`.
|
||||
- Shared used: none beyond `db/models`.
|
||||
|
||||
- `GET /api/public/campuses`
|
||||
## API
|
||||
|
||||
Response shape:
|
||||
The slice is mounted at `/api/public/campuses` in `src/index.ts`. Unlike the authenticated routes,
|
||||
this mount is registered before the `jwt` guard and has no authentication middleware — it is a public
|
||||
endpoint.
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"id": "campus uuid",
|
||||
"name": "Tigers Campus",
|
||||
"code": "tigers",
|
||||
"mascot": "Tigers",
|
||||
"color": "bg-orange-500",
|
||||
"bgGradient": "from-orange-500 to-amber-500",
|
||||
"borderColor": "border-orange-500/30",
|
||||
"textColor": "text-orange-400",
|
||||
"bgLight": "bg-orange-500/10",
|
||||
"description": "Strength, courage & determination",
|
||||
"isOnline": false
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
- `GET /api/public/campuses` -> `200` `{ rows, count }`. No request parameters are read (the
|
||||
controller ignores the request). Each row is the campus catalog DTO below; rows are ordered by
|
||||
`name` ascending. `count` is `rows.length`.
|
||||
|
||||
Only active campuses are returned. The endpoint is intentionally read-only and does not replace the authenticated `/api/campuses` CRUD workflow.
|
||||
## Access Rules
|
||||
|
||||
Campus identity, names, codes, mascot labels, online flag, descriptions, and branding tokens are campus data and belong in the `campuses` table.
|
||||
Public. No JWT, no role check, no per-user gating. Only campuses with `active = true` are returned.
|
||||
|
||||
## Seed Data
|
||||
## Tenant Scope
|
||||
|
||||
Initial product campuses are seeded by:
|
||||
None. The query filters solely on `active = true` and is not scoped to any organization or campus —
|
||||
it returns active campuses across all organizations.
|
||||
|
||||
- `backend/src/db/seeders/20260608100000-product-campuses.js`
|
||||
## Data Contract
|
||||
|
||||
The deleted generated sample-data seeder must not be reintroduced. Development or test-only rows belong in backend seeders or backend test fixtures, not frontend runtime constants.
|
||||
Campus catalog DTO (`toCampusCatalogDto`, also reflected in the service's selected `attributes`):
|
||||
`id`, `name`, `code`, `mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`,
|
||||
`description`, `isOnline`. Other campus columns (address, phone, email, organizationId, audit fields)
|
||||
are intentionally not exposed by this endpoint.
|
||||
|
||||
Relevant `campuses` model fields: `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null),
|
||||
`address` / `phone` / `email` (TEXT, nullable), `mascot`, `color`, `bgGradient`, `borderColor`,
|
||||
`textColor`, `bgLight`, `description` (all TEXT, nullable), `isOnline` (BOOLEAN, not null, default
|
||||
false), `active` (BOOLEAN, not null, default false), `importHash` (unique, nullable),
|
||||
`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
|
||||
on `campusId`).
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- The service selects only the catalog `attributes` so the SQL fetches just the columns the DTO needs.
|
||||
- Because the filter is `{ active: true }` with no tenant scope, inactive campuses are never returned
|
||||
and the result is not paginated.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `campus_catalog` / `public_campuses` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/campus-catalog.md`.
|
||||
- Related slices: campuses CRUD (authenticated `/api/campuses`, route `src/routes/campuses.ts`,
|
||||
repository `src/db/api/campuses.ts`) for create/update/delete of campus records.
|
||||
|
||||
107
backend/docs/campuses.md
Normal file
107
backend/docs/campuses.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Campuses Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`campuses` is the per-organization catalog of school campuses (branding tokens, contact details,
|
||||
online/active flags). This doc covers the **authenticated CRUD surface** at `/api/campuses`. The
|
||||
public read-only surface at `GET /api/public/campuses` is a separate slice documented in
|
||||
`campus-catalog.md` — it is not re-documented here. Note that `src/db/api/campuses.ts` is the
|
||||
repository for **this** authenticated slice; the public catalog slice queries `db.campuses` directly
|
||||
and does not go through this repository.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/campuses.ts` — `createCrudRouter(controller, { permission: 'campuses' })`.
|
||||
- Controller: `src/api/controllers/campuses.controller.ts` —
|
||||
`createCrudController(service, { csvFields: ['id', 'name', 'code', 'address', 'phone', 'email'] })`.
|
||||
- Service (BLL): `src/services/campuses.ts` —
|
||||
`createCrudService(DbApi, { notFoundCode: 'campusesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/campuses.ts` (`CampusesDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts` (autocomplete via `autocompleteByField`).
|
||||
- Model: `src/db/models/campuses.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` — `removeRecord`, `deleteRecordsByIds`, `autocompleteByField`),
|
||||
`shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/validation`
|
||||
(`ValidationError`), `db/utils` (`uuid`, `ilike`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/campuses`, JWT + `${METHOD}_CAMPUSES` 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`, `code`, `address`, `phone`, `email`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('campuses')`, deriving
|
||||
`READ_CAMPUSES` / `CREATE_CAMPUSES` / `UPDATE_CAMPUSES` / `DELETE_CAMPUSES` per HTTP method.
|
||||
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
|
||||
- The public `GET /api/public/campuses` surface has no JWT and no permission check — see
|
||||
`campus-catalog.md`.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- `findAll` scopes `where.organizationId` to `currentUser.organizationId` (only when the caller has
|
||||
both `currentUser.organizations.id` and `currentUser.organizationId`); a `globalAccess` caller has
|
||||
the `organizationId` filter deleted, so it sees all tenants.
|
||||
- `create` assigns the organization from `currentUser.organizationId` via `setOrganization`.
|
||||
- `update` only reassigns the organization when `data.organization` is provided: a `globalAccess`
|
||||
caller may set it to the supplied value; otherwise it is forced back to `currentUser.organizationId`.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
|
||||
|
||||
- `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `address`, `phone`, `email`,
|
||||
`mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`, `description` (all TEXT,
|
||||
nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default
|
||||
`false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable),
|
||||
`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
|
||||
`campusId`, `constraints: false`).
|
||||
|
||||
`findBy` (backing `GET /:id`) returns the plain campus plus all eight `hasMany` collections and the
|
||||
`organization`, fetched in a single `Promise.all`. `findAll` eager-loads only `organization`.
|
||||
|
||||
List filters (`CampusesFilter`): `id`, `name`, `code`, `address`, `phone`, `email` (all ilike),
|
||||
`active`, `organization` (`|`-separated org ids, matched on `organizationId`), `createdAtRange`, plus
|
||||
`field`/`sort` ordering and `limit`/`page` pagination. Default order is `createdAt desc`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create` and `bulkImport` both throw `ValidationError` when `name` or `code` is missing (`name` and
|
||||
`code` are not-null columns); `bulkImport` validates per row.
|
||||
- `create`/`update` manage the organization link via `setOrganization` rather than writing
|
||||
`organizationId` directly; `update` applies only the fields present in the body (each guarded by
|
||||
`!== undefined`).
|
||||
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
|
||||
- List pagination uses the shared `resolvePagination` defaults; the query uses `distinct: true`
|
||||
alongside the `organization` include.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- 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`.
|
||||
91
backend/docs/class_enrollments.md
Normal file
91
backend/docs/class_enrollments.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Class Enrollments Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`class_enrollments` is the per-organization join between `students` 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.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/class_enrollments.ts` — `createCrudRouter(controller, { permission: 'class_enrollments' })`.
|
||||
- Controller: `src/api/controllers/class_enrollments.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/class_enrollments.ts` — `createCrudService(DbApi, { notFoundCode: 'class_enrollmentsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/class_enrollments.ts` (`Class_enrollmentsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/class_enrollments.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/class_enrollments`, JWT +
|
||||
`${METHOD}_CLASS_ENROLLMENTS` 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**), 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
|
||||
`status` (the field passed to `autocompleteByField`).
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `enrolled_on`, `ended_on`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('class_enrollments')`,
|
||||
deriving `READ_CLASS_ENROLLMENTS` / `CREATE_CLASS_ENROLLMENTS` / `UPDATE_CLASS_ENROLLMENTS` /
|
||||
`DELETE_CLASS_ENROLLMENTS` 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).
|
||||
- `enrolled_on`, `ended_on` — DATE, nullable.
|
||||
- `status` — ENUM `active` | `dropped` | `completed`.
|
||||
- `importHash` (unique), `organizationId`, `classId`, `studentId`, `createdById`, `updatedById`,
|
||||
timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, class (classes), student (students),
|
||||
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
|
||||
the output as `class`).
|
||||
|
||||
List filters (`ClassEnrollmentsFilter`): `id`, `class` (id or name, `|`-separated), `student` (id
|
||||
or `student_number`, `|`-separated), `enrolled_onRange`, `ended_onRange`, `status`,
|
||||
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`update` wire the organization, class and student relations via the `set*` association
|
||||
mixins; `bulkImport` does not set these relations.
|
||||
- `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: `ClassEnrollmentsFilter` 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: `students`, `classes`,
|
||||
`permissions.md`.
|
||||
93
backend/docs/class_subjects.md
Normal file
93
backend/docs/class_subjects.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Class Subjects Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`class_subjects` is the per-organization join between `classes` and `subjects` — it represents a
|
||||
subject taught in a class, optionally assigned to a teacher (staff). It is a generic-CRUD slice
|
||||
assembled from the shared factories; the backend is the source of truth for these assignments.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/class_subjects.ts` — `createCrudRouter(controller, { permission: 'class_subjects' })`.
|
||||
- Controller: `src/api/controllers/class_subjects.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/class_subjects.ts` — `createCrudService(DbApi, { notFoundCode: 'class_subjectsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/class_subjects.ts` (`Class_subjectsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/class_subjects.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/class_subjects`, JWT +
|
||||
`${METHOD}_CLASS_SUBJECTS` 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**), 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
|
||||
`status` (the field passed to `autocompleteByField`).
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('class_subjects')`, deriving
|
||||
`READ_CLASS_SUBJECTS` / `CREATE_CLASS_SUBJECTS` / `UPDATE_CLASS_SUBJECTS` /
|
||||
`DELETE_CLASS_SUBJECTS` 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).
|
||||
- `status` — ENUM `active` | `archived`.
|
||||
- `importHash` (unique), `organizationId`, `classId`, `subjectId`, `teacherId`, `createdById`,
|
||||
`updatedById`, timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, class (classes), subject (subjects), teacher (staff),
|
||||
createdBy/updatedBy (users); `hasMany` `timetable_periods_class_subject`,
|
||||
`attendance_sessions_class_subject`, `assessments_class_subject`. `findBy`/`GET /:id` eager-load
|
||||
timetable_periods_class_subject, attendance_sessions_class_subject, assessments_class_subject,
|
||||
organization, class, subject and teacher in a single `Promise.all` (the class association is
|
||||
exposed on the output as `class`).
|
||||
|
||||
List filters (`ClassSubjectsFilter`): `id`, `class` (id or name, `|`-separated), `subject` (id or
|
||||
name, `|`-separated), `teacher` (id or `employee_number`, `|`-separated), `status`,
|
||||
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`update` wire the organization, class, subject and teacher relations via the `set*`
|
||||
association mixins; `bulkImport` does not set these relations.
|
||||
- `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: `ClassSubjectsFilter` accepts an `active` flag the model has no column for; it is
|
||||
currently inert (kept for source accuracy).
|
||||
- Note: only `id` is in `csvFields`, so the CSV export emits just the id column.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `classes`, `subjects`,
|
||||
`staff`, `timetable_periods`, `attendance_sessions`, `assessments`, `permissions.md`.
|
||||
93
backend/docs/classes.md
Normal file
93
backend/docs/classes.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Classes Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`classes` is the per-organization roster of teaching classes (a named, sectioned group of
|
||||
students, optionally tied to an academic year, grade, campus and homeroom teacher). It is a
|
||||
generic-CRUD slice assembled from the shared factories; the backend is the source of truth for
|
||||
class records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/classes.ts` — `createCrudRouter(controller, { permission: 'classes' })`.
|
||||
- Controller: `src/api/controllers/classes.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/classes.ts` — `createCrudService(DbApi, { notFoundCode: 'classesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/classes.ts` (`ClassesDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/classes.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/classes`, JWT + `${METHOD}_CLASSES`
|
||||
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**), 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`, `section`, `capacity`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('classes')`, deriving
|
||||
`READ_CLASSES` / `CREATE_CLASSES` / `UPDATE_CLASSES` / `DELETE_CLASSES` 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`, `section` — TEXT, nullable.
|
||||
- `capacity` — INTEGER, nullable.
|
||||
- `status` — ENUM `active` | `archived`.
|
||||
- `importHash` (unique), `academic_yearId`, `campusId`, `organizationId`, `gradeId`,
|
||||
`homeroom_teacherId`, `createdById`, `updatedById`, timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, campus, academic_year (academic_years), grade (grades),
|
||||
homeroom_teacher (staff), createdBy/updatedBy (users); `hasMany` `class_enrollments_class`,
|
||||
`class_subjects_class`, `attendance_sessions_class`. `findBy`/`GET /:id` eager-load
|
||||
class_enrollments_class, class_subjects_class, attendance_sessions_class, organization, campus,
|
||||
academic_year, grade and homeroom_teacher in a single `Promise.all`.
|
||||
|
||||
List filters (`ClassesFilter`): `id`, `name` (ilike), `section` (ilike), `capacityRange`,
|
||||
`status`, `campus` (id or name, `|`-separated), `academic_year` (id or name, `|`-separated),
|
||||
`grade` (id or name, `|`-separated), `homeroom_teacher` (id or `employee_number`, `|`-separated),
|
||||
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`update` wire the organization, campus, academic_year, grade and homeroom_teacher
|
||||
relations via the `set*` association mixins; `bulkImport` does not set these relations.
|
||||
- `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: `ClassesFilter` 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: `class_enrollments`,
|
||||
`class_subjects`, `campuses`, `academic_years`, `grades`, `staff`, `attendance_sessions`,
|
||||
`permissions.md`.
|
||||
@ -1,49 +1,59 @@
|
||||
# Communications Backend
|
||||
|
||||
## Purpose
|
||||
The communications slice exposes product-focused endpoints for parent messages and internal alert events, instead of the generated CRUD routes. Parent messages reuse the existing `messages` and `message_recipients` tables; internal alerts own the `communication_events` table. The backend is the source of truth for these records.
|
||||
|
||||
The communications API provides product-focused endpoints for parent messages and internal alerts without exposing the generated CRUD routes directly to the frontend workflow.
|
||||
|
||||
Parent messages reuse existing backend tables:
|
||||
|
||||
- `messages`
|
||||
- `message_recipients`
|
||||
|
||||
Internal alerts use:
|
||||
|
||||
- `communication_events`
|
||||
## Slice Files (by layer)
|
||||
- Route: `src/routes/communications.ts` (thin wiring; `GET /parent-messages`, `POST /parent-messages`, `GET /events`, `POST /events`). Mounted at `/api/communications` behind the `authenticated` middleware in `src/index.ts`.
|
||||
- Controller: `src/api/controllers/communications.controller.ts` (custom — `listParentMessages`, `createParentMessage`, `listEvents`, `createEvent`).
|
||||
- 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`.
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
|
||||
- `GET /api/communications/parent-messages`: returns parent messages created by the current user.
|
||||
- `GET /api/communications/parent-messages?category=<category>`: filters the current user's parent messages by category.
|
||||
- `POST /api/communications/parent-messages`: creates one sent parent message and recipient log.
|
||||
- `GET /api/communications/events`: returns internal alert events visible to the current user's organization and campus scope.
|
||||
- `GET /api/communications/events?type=<type>`: filters events by type.
|
||||
- `POST /api/communications/events`: creates one internal alert event.
|
||||
- `GET /api/communications/parent-messages` -> `200` `{ rows, count }`. Optional query `category`; supports `limit`/`page`.
|
||||
- `POST /api/communications/parent-messages` -> `201` the created parent-message DTO. Body is `req.body.data`.
|
||||
- `GET /api/communications/events` -> `200` `{ rows, count }`. Optional query `type`; supports `limit`/`page`.
|
||||
- `POST /api/communications/events` -> `201` the created event DTO. Body is `req.body.data`.
|
||||
|
||||
Parent-message DTO fields: `id`, `text` (from `body`), `to` (first recipient `recipient_label`), `date` (ISO, from `sent_at` or `createdAt`), `category` (derived from `subject`), `sentAt`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
||||
|
||||
Event DTO fields: `id`, `title`, `date` (from `event_date`), `type` (from `event_type`), `roles`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
||||
|
||||
## Access Rules
|
||||
- All endpoints require an authenticated tenant user (`assertAuthenticatedTenantUser`).
|
||||
- `POST /events` additionally requires manage access (`assertCanManageCommunications`): the user must hold one of `COMMUNICATION_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`.
|
||||
- Listing and creating parent messages requires only an authenticated tenant user.
|
||||
- The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user.
|
||||
|
||||
- Authenticated tenant users can create and list their own parent-message logs.
|
||||
- Communication manager roles can create internal alert events.
|
||||
- Tenant-wide roles can list all organization events.
|
||||
- Campus-scoped users list events for their campus when a campus is available on their profile.
|
||||
## Tenant Scope
|
||||
- `GET /parent-messages` filters by organization via `getOrganizationIdOrGlobal(currentUser)`:
|
||||
global access users see messages across all organizations; regular users see only their own org.
|
||||
Global access users also see all users' messages; regular users see only their own (`createdById`).
|
||||
Audience is always `guardians`, plus `campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES)`.
|
||||
- `GET /events` filters by organization via `getOrganizationIdOrGlobal(currentUser)` plus the same
|
||||
`campusScope`. Global access users see events across all organizations.
|
||||
- `campusScope` (from `services/shared/access.ts`): tenant-wide roles (`COMMUNICATION_TENANT_WIDE_ROLE_NAMES` — super admin, admin, platform owner, tenant director) or global access see all of the organization; other users are restricted to their profile `campusId` when one is present.
|
||||
- On create, `organizationId` and `campusId` are derived from the user (`getOrganizationIdOrGlobal`, `getCampusId`).
|
||||
|
||||
## 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`).
|
||||
- `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)`.
|
||||
|
||||
Parent message create fields:
|
||||
## Behavior / Notes
|
||||
- `createParentMessage` runs inside `withTransaction`: creates a `messages` row (`subject = category`, `body = messageText`, `channel = in_app`, `audience = guardians`, `sent_at = now`, `status = sent`) and a matching `message_recipients` row (`recipient_type = guardian`, `recipient_label = recipientName`, `delivery_status = sent`, `delivered_at = now`), then re-reads the message with its recipient to build the DTO.
|
||||
- Parent-message list includes `message_recipients` (alias `message_recipients_message`, only `recipient_label`) and orders by `sent_at desc`, then `createdAt desc`.
|
||||
- Event list orders by `event_date asc`, then `createdAt desc`. `createEvent` is a single create (no transaction).
|
||||
- Validation failures throw `ValidationError`; access failures throw `ForbiddenError`.
|
||||
|
||||
- `recipientName`
|
||||
- `messageText`
|
||||
- `category`
|
||||
## Tests
|
||||
None yet (no `*.test.ts` under `backend/src` references this slice).
|
||||
|
||||
Event create fields:
|
||||
|
||||
- `title`
|
||||
- `date`
|
||||
- `type`
|
||||
- `roles`
|
||||
|
||||
The frontend does not send organization, campus, creator, or updater fields. The backend fills them from the authenticated user.
|
||||
## Related
|
||||
- Frontend: `frontend/docs/communications-integration.md`.
|
||||
- Related backend slice: content catalog (`backend/docs/content-catalog.md`) backs safety protocols and parent-message templates referenced by the communications UI.
|
||||
|
||||
@ -1,94 +1,61 @@
|
||||
# Content Catalog
|
||||
# Content Catalog Backend
|
||||
|
||||
## Purpose
|
||||
`content_catalog` stores backend-owned, seeded product content keyed by `content_type`, which the frontend renders through backend APIs. The database and backend seeds are the source of truth for these domain/content records, instead of duplicating them in frontend runtime constants. A public read endpoint serves the active payload for a content type, and authenticated management endpoints allow runtime configuration of catalog records.
|
||||
|
||||
`content_catalog` stores backend-owned seeded product content that the frontend renders through backend APIs.
|
||||
|
||||
This keeps the database and backend seeds as the source of truth for domain/content records instead of duplicating those records in frontend runtime constants.
|
||||
|
||||
## Files
|
||||
|
||||
- Migration: `backend/src/db/migrations/20260608102000-create-content-catalog.js`
|
||||
- Model: `backend/src/db/models/content_catalog.js`
|
||||
- Service: `backend/src/services/content_catalog.js`
|
||||
- Route: `backend/src/routes/public_content_catalog.js`
|
||||
- Management route: `backend/src/routes/content_catalog.js`
|
||||
- Seed payloads: `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js`
|
||||
- Seeder: `backend/src/db/seeders/20260608103000-content-catalog.js`
|
||||
## Slice Files (by layer)
|
||||
- Routes:
|
||||
- `src/routes/public_content_catalog.ts` — public read (`GET /:contentType`). Mounted at `/api/public/content-catalog` in `src/index.ts` (NOT behind the `authenticated` middleware).
|
||||
- `src/routes/content_catalog.ts` — management (`GET /`, `POST /`, `GET /:contentType`, `PUT /:contentType`, `DELETE /:contentType`). Mounted at `/api/content-catalog` behind the `authenticated` middleware.
|
||||
- Controllers:
|
||||
- `src/api/controllers/public_content_catalog.controller.ts` (`findByType`).
|
||||
- `src/api/controllers/content_catalog.controller.ts` (`list`, `create`, `findManagedByType`, `update`, `remove`).
|
||||
- Service (BLL): `src/services/content_catalog.ts` (single `ContentCatalogService`: `list`, `findByType`, `findManagedByType`, `create`, `update`, `delete`).
|
||||
- Repository (DAL): queries run through `db.content_catalog` inside the service (no separate `db/api` file).
|
||||
- Model: `src/db/models/content_catalog.ts` (no model associations).
|
||||
- Shared used: `db/with-transaction.ts`, `services/shared/access.ts` (`hasRoleAccess`), `shared/constants/content-catalog.ts` (`CONTENT_CATALOG_MANAGER_ROLE_NAMES`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/forbidden.ts`, `shared/errors/validation.ts`.
|
||||
- Seeds: `src/db/seeders/20260608103000-content-catalog.ts` with payloads in `src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`.
|
||||
|
||||
## API
|
||||
- `GET /api/public/content-catalog/:contentType` -> `200` the active content DTO for that `contentType`. No JWT required. Throws `ValidationError('contentCatalogNotFound')` when no active record exists.
|
||||
- `GET /api/content-catalog` -> `200` `{ rows, count }`. JWT + manage access. Supports `limit`/`page`.
|
||||
- `POST /api/content-catalog` -> `201` the created DTO. JWT + manage access. Body is `req.body.data`.
|
||||
- `GET /api/content-catalog/:contentType` -> `200` the active DTO for that type. JWT + manage access (delegates to `findByType`).
|
||||
- `PUT /api/content-catalog/:contentType` -> `200` the updated DTO. JWT + manage access. Body is `req.body.data`.
|
||||
- `DELETE /api/content-catalog/:contentType` -> `204` no body. JWT + manage access.
|
||||
|
||||
Public read endpoint:
|
||||
DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
|
||||
|
||||
- `GET /api/public/content-catalog/:contentType`
|
||||
## Access Rules
|
||||
- The public read endpoint (`/api/public/content-catalog/:contentType`) is unauthenticated and applies no role check; it only returns records where `active = true`.
|
||||
- All `/api/content-catalog` management endpoints require manage access (`assertCanManageContentCatalog`): the user must hold one of `CONTENT_CATALOG_MANAGER_ROLE_NAMES` (super admin, admin, platform owner, tenant director, campus manager) or have global access; otherwise `ForbiddenError`. (`hasRoleAccess` is the only gate; there is no separate `assertAuthenticatedTenantUser` call in this service.)
|
||||
|
||||
The response returns one active content payload for the requested `contentType`.
|
||||
## Tenant Scope
|
||||
None. `content_catalog` has no `organizationId`/`campusId` columns and the service applies no tenant or campus filtering; records are global. `content_type` is unique across the table.
|
||||
|
||||
Authenticated management endpoints:
|
||||
## Data Contract
|
||||
- Create input: `content_type` (required non-empty string), `payload` (required; any non-`undefined` JSON value), optional `active` (defaults to `true` unless explicitly `false`), optional `importHash`.
|
||||
- Update input: `payload` (required), optional `active` (set to `true` unless explicitly `false`).
|
||||
- Model fields: `id` (UUID), `content_type` (text, unique, not null), `payload` (JSONB, not null), `active` (boolean, not null, default `true`), `importHash` (nullable, unique), `createdAt`, `updatedAt`, `deletedAt`. `paranoid` soft deletes; `freezeTableName`.
|
||||
- List pagination: `list` uses `resolvePagination(limit, page)` and orders by `content_type asc`.
|
||||
|
||||
- `GET /api/content-catalog`
|
||||
- `POST /api/content-catalog`
|
||||
- `GET /api/content-catalog/:contentType`
|
||||
- `PUT /api/content-catalog/:contentType`
|
||||
- `DELETE /api/content-catalog/:contentType`
|
||||
## Behavior / Notes
|
||||
- `create` looks up any existing row by `content_type` with `paranoid: false`. If a non-deleted row exists it throws `ValidationError`. If a soft-deleted row exists it is restored and updated inside `withTransaction`; otherwise a new row is created.
|
||||
- `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the row is missing. `delete` sets `active = false` then soft-deletes (`destroy`).
|
||||
- `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record.
|
||||
- Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads.
|
||||
|
||||
Management endpoints are restricted to global access users and content managers. They allow runtime configuration for catalog-backed modules such as QBS quiz content, while the frontend runtime continues to consume active records through the public read endpoint.
|
||||
|
||||
## Seeded Content Types
|
||||
|
||||
- `classroom-strategies`
|
||||
- `safety-qbs-quiz`
|
||||
- `sign-language-items`
|
||||
- `sign-language-page-content`
|
||||
- `regulation-zones`
|
||||
- `zones-of-regulation-page-content`
|
||||
- `dashboard-teacher-images`
|
||||
- `dashboard-encouraging-quotes`
|
||||
- `dashboard-compliance-items`
|
||||
- `dashboard-sign-of-week`
|
||||
- `parent-message-templates`
|
||||
- `community-organizations`
|
||||
- `vocational-opportunities`
|
||||
- `emotional-intelligence-assessment-questions`
|
||||
- `emotional-intelligence-weekly-topics`
|
||||
- `emotional-intelligence-growth-tips`
|
||||
- `emotional-intelligence-team-wellness-metrics`
|
||||
- `emotional-intelligence-weekly-focus`
|
||||
- `personality-quiz-questions`
|
||||
- `personality-types`
|
||||
- `personality-workplace-content`
|
||||
- `esa-funding-content`
|
||||
- `safety-protocols`
|
||||
- `classroom-timer-backgrounds`
|
||||
- `classroom-timer-sounds`
|
||||
- `classroom-timer-presets`
|
||||
- `classroom-timer-tips`
|
||||
- `personality-quiz-features`
|
||||
|
||||
## Rules
|
||||
### Seeded content types
|
||||
The seeder (`20260608103000-content-catalog.ts`) loads the following `content_type` keys from `content-catalog-seed-payloads.ts`: `classroom-strategies`, `safety-qbs-quiz`, `sign-language-items`, `sign-language-page-content`, `regulation-zones`, `zones-of-regulation-page-content`, `dashboard-teacher-images`, `dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, `parent-message-templates`, `community-organizations`, `vocational-opportunities`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-weekly-topics`, `emotional-intelligence-growth-tips`, `emotional-intelligence-team-wellness-metrics`, `emotional-intelligence-weekly-focus`, `personality-quiz-questions`, `personality-types`, `personality-workplace-content`, `esa-funding-content`, `safety-protocols`, `classroom-timer-backgrounds`, `classroom-timer-sounds`, `classroom-timer-presets`, `classroom-timer-tips`, `personality-quiz-features`.
|
||||
|
||||
### Content authoring rules
|
||||
- Add production content records to backend seed payloads, not frontend constants.
|
||||
- Keep frontend constants limited to UI config, labels, query keys, timing values, and presentation tokens.
|
||||
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed backend tables and tenant-scoped CRUD APIs.
|
||||
- Missing content types fail explicitly through the service instead of returning silent empty payloads.
|
||||
- Do not duplicate catalog payload copy in frontend constants. Frontend may define TypeScript contracts and UI-only labels, but editable content belongs to the backend.
|
||||
- Frontend constants stay limited to UI config, labels, query keys, timing values, and presentation tokens.
|
||||
- If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs.
|
||||
|
||||
## Classroom Strategies
|
||||
## Tests
|
||||
None yet (no `*.test.ts` under `backend/src` references this slice).
|
||||
|
||||
`classroom-strategies` stores strategy records rendered by the classroom support page. Titles, descriptions, images, categories, age groups, zones, and implementation tips are backend-owned content fields and should be edited through catalog management APIs or backend seeds.
|
||||
|
||||
## Sign Language
|
||||
|
||||
`sign-language-items` stores sign records rendered by the sign language page. Teaching tips, video URLs, GIF URLs, and step instructions are backend-owned content fields.
|
||||
|
||||
`sign-language-page-content` stores page-level teaching reminders for the sign language page. This keeps editable instructional copy out of frontend runtime constants.
|
||||
|
||||
## Zones Of Regulation
|
||||
|
||||
`regulation-zones` stores zone records rendered by the zones of regulation page. Zone descriptions, behaviors, strategies, matching signs, and zone presentation classes are backend-owned content fields.
|
||||
|
||||
`zones-of-regulation-page-content` stores page-level safety connection copy and quick de-escalation flow content for the zones page. This keeps editable instructional copy out of frontend runtime constants.
|
||||
|
||||
## ESA Funding
|
||||
|
||||
`esa-funding-content` stores editable ESA funding content rendered by the ESA funding page. Approved uses, key points, state checklist items, school impact items, staff role guidance, parent conversation script, and resource records are backend-owned content fields. Static ESA intro copy and FAQs live in frontend constants because they are stable training copy rather than editable runtime records.
|
||||
## Related
|
||||
- Frontend: `frontend/docs/content-catalog-integration.md`.
|
||||
- Related backend slice: communications (`backend/docs/communications.md`) — its UI consumes `parent-message-templates` and `safety-protocols` from this catalog.
|
||||
|
||||
@ -2,55 +2,156 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser authentication uses a backend-owned HttpOnly cookie. The product frontend does not store, read, or send auth tokens manually.
|
||||
Browser authentication uses backend-owned HttpOnly cookies: a short-lived
|
||||
access cookie carrying a signed JWT, and a long-lived opaque refresh cookie
|
||||
backed by hashed, rotating refresh-token rows. The product frontend never
|
||||
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`.
|
||||
|
||||
## Runtime Contract
|
||||
## Slice Files (by layer)
|
||||
|
||||
- `POST /api/auth/signin/local` validates credentials, sets access and refresh HttpOnly cookies, and returns the current user profile.
|
||||
- `GET /api/auth/me` authenticates from the access cookie and returns the current user profile.
|
||||
- `POST /api/auth/refresh` authenticates from the refresh cookie, rotates it, sets fresh cookies, and returns the current user profile.
|
||||
- `POST /api/auth/signout` revokes the active refresh token, clears both cookies, and returns `204 No Content`.
|
||||
- Social auth callbacks set access and refresh cookies and redirect to the frontend without token query parameters.
|
||||
- Route: `src/routes/auth.ts` (mounted at `/api/auth` in `src/index.ts`).
|
||||
`/signin/local`, `/refresh`, and `/signout` are unauthenticated routes that
|
||||
read/set/clear cookies; `/me` is JWT-authenticated.
|
||||
- Controller: `src/api/controllers/auth.controller.ts` — sets cookies via
|
||||
`cookies.setSessionCookies`, reads the refresh cookie via
|
||||
`cookies.extractRefreshCookie`, clears via `cookies.clearSessionCookies`.
|
||||
- Service (BLL): `src/services/auth.ts` (`createSession`, `refreshSession`,
|
||||
`revokeSession`) with `SessionOptions` in `src/services/auth.types.ts`.
|
||||
- Cookie helpers: `src/auth/cookies.ts`.
|
||||
- Passport JWT strategy: `src/auth/auth.ts` (reads the access cookie via
|
||||
`cookies.extractAccessCookie`).
|
||||
- Origin middleware: `src/middlewares/csrf-origin.ts`.
|
||||
- Repositories (DAL): `src/db/api/auth_refresh_tokens.ts`
|
||||
(`AuthRefreshTokensDBApi`), `src/db/api/users.ts` (`UsersDBApi.findBy`).
|
||||
- Model: `src/db/models/auth_refresh_tokens.ts`.
|
||||
- Shared used: `shared/config` (cookie/CORS/token config), `shared/jwt`
|
||||
(`jwtSign`), `shared/constants/auth.ts` (cookie names, TTLs, token bytes,
|
||||
hash algorithm, unsafe methods), `shared/errors/forbidden.ts`.
|
||||
|
||||
## API
|
||||
|
||||
Base path `/api/auth`.
|
||||
|
||||
- `POST /api/auth/signin/local` -> `200` the current user profile. Validates
|
||||
credentials (`AuthService.signin`), creates a session, sets the access and
|
||||
refresh HttpOnly cookies.
|
||||
- `POST /api/auth/refresh` -> `200` the current user profile. Reads the refresh
|
||||
cookie, rotates it (`AuthService.refreshSession`), sets fresh cookies. Returns
|
||||
`ForbiddenError` when the refresh cookie is missing/invalid/expired/revoked.
|
||||
- `POST /api/auth/signout` -> `204 No Content`. Revokes the active refresh token
|
||||
(`AuthService.revokeSession`) and clears both cookies. Missing/already-revoked
|
||||
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.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Protected API routes authenticate through the access cookie. The Passport JWT
|
||||
strategy (`src/auth/auth.ts`) extracts the token with
|
||||
`cookies.extractAccessCookie`, verifies it with `config.secret_key`, loads the
|
||||
user by email, and rejects disabled users.
|
||||
- The JWT payload is `{ user: { id, email } }`, signed by `jwtSign`.
|
||||
- `/refresh` and `/signout` are not Passport-guarded; they authenticate solely
|
||||
from the refresh cookie value.
|
||||
|
||||
## CSRF / Origin Protection
|
||||
|
||||
- `src/middlewares/csrf-origin.ts` (`csrfOrigin`) is applied to all `/api`
|
||||
routes via `app.use('/api', csrfOrigin)` in `src/index.ts`.
|
||||
- Safe methods pass through; only unsafe methods are checked
|
||||
(`UNSAFE_HTTP_METHODS` = `POST`, `PUT`, `PATCH`, `DELETE`).
|
||||
- The source origin is taken from the `Origin` header, falling back to the
|
||||
`Referer` header (parsed to its origin).
|
||||
- The request is allowed when `config.auth.allowAllOrigins` is true and a source
|
||||
origin is present, or when the source origin is in
|
||||
`config.auth.allowedOrigins`. Otherwise it is rejected with `ForbiddenError`.
|
||||
- `allowAllOrigins` is enabled only in the `dev_stage` (production-like,
|
||||
non-production) environment when `ALLOWED_ORIGINS` is not set; strict
|
||||
production requires an explicit allow-list.
|
||||
|
||||
## Refresh Tokens
|
||||
|
||||
Auth uses access/refresh cookie rotation:
|
||||
Access/refresh rotation (`src/services/auth.ts` + `auth_refresh_tokens`):
|
||||
|
||||
- short-lived access cookie for normal Passport-protected API requests
|
||||
- long-lived opaque refresh cookie used only by `POST /api/auth/refresh`
|
||||
- hashed refresh token storage in the backend database
|
||||
- refresh-token rotation on every successful refresh
|
||||
- refresh-token family revocation when revoked-token reuse is detected
|
||||
- both cookies cleared on sign-out
|
||||
- `createSession` mints a signed access JWT and an opaque refresh token
|
||||
(`crypto.randomBytes(config.auth.refreshTokenBytes).toString('base64url')`),
|
||||
stores the SHA-256 hash (`config.auth.refreshTokenHashAlgorithm`) in
|
||||
`auth_refresh_tokens` with `userId`, `organizationId`, `familyId`,
|
||||
`previousTokenId`, `userAgent`, `ipAddress`, and `expiresAt`
|
||||
(`now + config.auth.refreshTokenMaxAgeMs`). Only the hash is persisted.
|
||||
- `refreshSession` runs in a transaction: looks up the token by hash, then
|
||||
rejects with `ForbiddenError` and:
|
||||
- revokes the whole family (`revokeFamily`) when the token is already revoked
|
||||
(reuse detection);
|
||||
- revokes the single token when it is expired;
|
||||
- revokes the family when the user is missing or disabled.
|
||||
On success it creates a new session in the same `familyId` (setting
|
||||
`previousTokenId`) and marks the old token revoked with `replacedByTokenId`
|
||||
pointing at the new row (rotation).
|
||||
- `revokeSession` looks up the token by hash and revokes it
|
||||
(`replacedByTokenId` null); missing or already-revoked tokens are a no-op.
|
||||
|
||||
The frontend must not read or receive access or refresh tokens. It should only send credentialed requests and perform one controlled refresh-and-retry when an access cookie expires. If both access and refresh credentials are expired or invalid, the frontend must redirect to `/login` instead of showing a raw session error.
|
||||
## Cookie Behavior
|
||||
|
||||
`src/auth/cookies.ts`:
|
||||
|
||||
- Access cookie name: `config.auth.accessCookieName`
|
||||
(env `AUTH_ACCESS_COOKIE_NAME`, default `AUTH_COOKIE_NAME` =
|
||||
`school_chain_session`). Max-age `config.auth.accessTokenMaxAgeMs`
|
||||
(env `AUTH_COOKIE_MAX_AGE_MS`, default `JWT_EXPIRES_IN_MS` = 15 minutes).
|
||||
- Refresh cookie name: `config.auth.refreshCookieName`
|
||||
(env `AUTH_REFRESH_COOKIE_NAME`, default `AUTH_REFRESH_COOKIE_NAME` =
|
||||
`school_chain_refresh`). Max-age `config.auth.refreshTokenMaxAgeMs`
|
||||
(env `AUTH_REFRESH_TOKEN_MAX_AGE_MS`, default `REFRESH_TOKEN_EXPIRES_IN_MS` =
|
||||
14 days).
|
||||
- Both cookies are set with `httpOnly: true`, `path` = `config.auth.cookiePath`
|
||||
(`/`), `sameSite` = `config.auth.cookieSameSite`, `secure` =
|
||||
`config.auth.cookieSecure`, and `domain` = `config.auth.cookieDomain` when
|
||||
configured.
|
||||
- `clearSessionCookies` clears both cookies using the same path/domain.
|
||||
- Cookies are read by manually parsing the `Cookie` header
|
||||
(`extractCookie` / `extractAccessCookie` / `extractRefreshCookie`).
|
||||
|
||||
## Configuration
|
||||
|
||||
Cookie and CORS values are configured through `backend/src/config.js` from non-secret environment values:
|
||||
`src/shared/config/index.ts` (`config.auth`), from constants in
|
||||
`src/shared/constants/auth.ts` and environment values:
|
||||
|
||||
- `ALLOWED_ORIGINS`
|
||||
- `AUTH_COOKIE_NAME`
|
||||
- `AUTH_COOKIE_SAME_SITE`
|
||||
- `AUTH_COOKIE_SECURE`
|
||||
- `AUTH_COOKIE_MAX_AGE_MS`
|
||||
- `AUTH_COOKIE_DOMAIN`
|
||||
- `ALLOWED_ORIGINS` -> `allowedOrigins`
|
||||
- `AUTH_ACCESS_COOKIE_NAME` -> `accessCookieName`
|
||||
- `AUTH_REFRESH_COOKIE_NAME` -> `refreshCookieName`
|
||||
- `AUTH_COOKIE_SAME_SITE` -> `cookieSameSite` (default `lax`)
|
||||
- `AUTH_COOKIE_SECURE` -> `cookieSecure` (default: true in production-like envs)
|
||||
- `AUTH_COOKIE_MAX_AGE_MS` -> `accessTokenMaxAgeMs`
|
||||
- `AUTH_REFRESH_TOKEN_MAX_AGE_MS` -> `refreshTokenMaxAgeMs`
|
||||
- `AUTH_COOKIE_DOMAIN` -> `cookieDomain`
|
||||
|
||||
Access and refresh cookie names and lifetimes are part of this config contract.
|
||||
Validation in config: `AUTH_COOKIE_SECURE` must be true when
|
||||
`AUTH_COOKIE_SAME_SITE` is `none` and in any production-like environment, and
|
||||
`ALLOWED_ORIGINS` must be set in production.
|
||||
|
||||
Secrets remain in environment variables only, especially `SECRET_KEY` and OAuth/email credentials.
|
||||
|
||||
## Code Ownership
|
||||
|
||||
- Cookie helpers live in `backend/src/auth/cookies.js`.
|
||||
- Passport JWT extraction reads the cookie in `backend/src/auth/auth.js`.
|
||||
- CSRF origin checks live in `backend/src/middlewares/csrf-origin.js`.
|
||||
- Auth routes own setting and clearing cookies through helper functions.
|
||||
Secrets remain in environment variables only, especially `SECRET_KEY` and
|
||||
OAuth/email credentials.
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Auth tokens must not be returned in response bodies.
|
||||
- Auth tokens must not be placed in redirect URLs.
|
||||
- Protected browser API routes must authenticate through the HttpOnly cookie.
|
||||
- Credentialed CORS must allow only configured frontend origins.
|
||||
- Unsafe methods are protected by Origin/Referer validation.
|
||||
- Protected browser API routes authenticate through the HttpOnly access cookie.
|
||||
- Only the refresh-token hash is stored in the database.
|
||||
- Unsafe methods are protected by Origin/Referer validation against the
|
||||
configured allow-list.
|
||||
- Refresh-token reuse triggers family revocation.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no auth unit/e2e test under `backend/src`).
|
||||
|
||||
## Related
|
||||
|
||||
- Profile and permission model: `backend/docs/auth-profile.md`.
|
||||
- Frontend: `frontend/docs/auth-integration.md`.
|
||||
|
||||
1232
backend/docs/database-schema.md
Normal file
1232
backend/docs/database-schema.md
Normal file
File diff suppressed because it is too large
Load Diff
111
backend/docs/documents.md
Normal file
111
backend/docs/documents.md
Normal file
@ -0,0 +1,111 @@
|
||||
# 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`.
|
||||
105
backend/docs/email.md
Normal file
105
backend/docs/email.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Email Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
The transactional email subsystem sends the three account lifecycle messages the
|
||||
backend produces: email-address verification, password reset, and invitation. It owns
|
||||
the SMTP transport (`EmailSender`) and the per-message classes that render an HTML body
|
||||
from a template file. It is infrastructure, not an HTTP slice: nothing here defines
|
||||
routes; the auth and users services call into it.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/services/email/index.ts` — `EmailSender` (default export): the transport wrapper
|
||||
and `isConfigured` guard.
|
||||
- `src/services/email/list/addressVerification.ts` — `EmailAddressVerificationEmail`.
|
||||
- `src/services/email/list/passwordReset.ts` — `PasswordResetEmail`.
|
||||
- `src/services/email/list/invitation.ts` — `InvitationEmail`.
|
||||
- `src/services/email/htmlTemplates/addressVerification/emailAddressVerification.html`
|
||||
- `src/services/email/htmlTemplates/passwordReset/passwordResetEmail.html`
|
||||
- `src/services/email/htmlTemplates/invitation/invitationTemplate.html`
|
||||
- `scripts/copy-assets.mjs` — copies the `htmlTemplates/` tree into `dist/` for the
|
||||
compiled build.
|
||||
|
||||
Shared used: `@/shared/config` (the `email` config block), `@/shared/logger`,
|
||||
`@/shared/notifications/helpers` (`getNotification`), Node `fs`/`path`, `nodemailer`.
|
||||
|
||||
## Public Interface
|
||||
|
||||
`EmailSender` (`src/services/email/index.ts`):
|
||||
|
||||
- `new EmailSender(email: EmailMessage)` where
|
||||
`EmailMessage = { to: string; subject: string; html: () => Promise<string> }`.
|
||||
- `send(): Promise<...>` — asserts `email`, `email.to`, `email.subject`, `email.html`
|
||||
are present, awaits `email.html()` to build the body, creates a `nodemailer` transport
|
||||
from `transportConfig`, and sends a mail with `from`, `to`, `subject`, the HTML body,
|
||||
and the header `X-SES-CONFIGURATION-SET: flatlogic-app`. Returns the
|
||||
`transporter.sendMail(...)` result.
|
||||
- `static get isConfigured(): boolean` — `true` only when both `config.email.auth.pass`
|
||||
and `config.email.auth.user` are set.
|
||||
- `get transportConfig()` — returns `config.email`.
|
||||
- `get from()` — returns `config.email.from`.
|
||||
|
||||
Each message class takes its recipient plus a link/host in the constructor, exposes a
|
||||
`subject` getter, and an `async html(): Promise<string>` that loads its template and
|
||||
substitutes placeholders. They are passed to `new EmailSender(...).send()`:
|
||||
|
||||
- `EmailAddressVerificationEmail(to: string, link: string)` — subject from
|
||||
`getNotification('emails.emailAddressVerification.subject', getNotification('app.title'))`;
|
||||
`html()` reads `addressVerification/emailAddressVerification.html` and replaces
|
||||
`{appTitle}` with `app.title`, `{signupUrl}` with `link`, `{to}` with `to`.
|
||||
- `PasswordResetEmail(to: string, link: string)` — subject from
|
||||
`getNotification('emails.passwordReset.subject', getNotification('app.title'))`;
|
||||
`html()` reads `passwordReset/passwordResetEmail.html` and replaces `{appTitle}` with
|
||||
`app.title`, `{resetUrl}` with `link`, `{accountName}` with `to`.
|
||||
- `InvitationEmail(to: string, host: string)` — subject from
|
||||
`getNotification('emails.invitation.subject', getNotification('app.title'))`;
|
||||
`html()` reads `invitation/invitationTemplate.html`, builds
|
||||
`signupUrl = `${host}&invitation=true`` and replaces `{appTitle}` with `app.title`,
|
||||
`{signupUrl}` with that URL, `{to}` with `to`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- SMTP configuration is read from `config.email` (`src/shared/config/index.ts`), built
|
||||
from environment variables with constant defaults from `src/shared/constants/app.ts`:
|
||||
`from` (`EMAIL_FROM`, default `School Chain Manager <app@flatlogic.app>`), `host`
|
||||
(`EMAIL_HOST`, default `email-smtp.us-east-1.amazonaws.com`), `port` (`EMAIL_PORT`,
|
||||
default `587`), `auth.user` (`EMAIL_USER`, default empty string), `auth.pass`
|
||||
(`EMAIL_PASS`, no default), and `tls.rejectUnauthorized: false`. Because `isConfigured`
|
||||
requires both `auth.user` and `auth.pass`, email sending is effectively disabled until
|
||||
both are provided.
|
||||
- Template loading uses `import.meta.dirname` (aliased to `__dirname` in each file) to
|
||||
resolve the template path relative to the running module, then `fs.readFile(path, 'utf8')`.
|
||||
Substitution is plain global `String.replace` of the `{placeholder}` tokens. On read or
|
||||
render failure each class logs via `logger.error` and rethrows.
|
||||
- Because `tsc` only emits `.ts` and these templates are read at runtime from a path
|
||||
derived from `import.meta`, `scripts/copy-assets.mjs` copies `src/services/email/htmlTemplates`
|
||||
into `dist/services/email/htmlTemplates` so the compiled production build can find them.
|
||||
|
||||
## Used By
|
||||
|
||||
- `src/services/auth.ts` (`AuthService`):
|
||||
- `signup` and the related flow call `sendEmailAddressVerificationEmail(email, host)`
|
||||
only when `EmailSender.isConfigured`.
|
||||
- `signin` treats the user as `emailVerified` when `EmailSender.isConfigured` is false
|
||||
(so unconfigured environments do not block login).
|
||||
- `sendEmailAddressVerificationEmail(email, host?)` generates a verification token,
|
||||
builds `${host}/verify-email?token=...`, and sends an `EmailAddressVerificationEmail`.
|
||||
- `sendPasswordResetEmail(email, type = 'register' | 'invitation', host?)` generates a
|
||||
password-reset token, builds `${host}/password-reset?token=...`, and sends either an
|
||||
`InvitationEmail` (when `type === 'invitation'`) or a `PasswordResetEmail` otherwise.
|
||||
- `src/api/controllers/auth.controller.ts` exposes `EmailSender.isConfigured` over HTTP
|
||||
(`res.status(200).send(EmailSender.isConfigured)`) and calls the two `AuthService` send
|
||||
methods from its request handlers.
|
||||
- `src/services/users.ts` (`UsersService`): `create` and `bulkImport` call
|
||||
`AuthService.sendPasswordResetEmail(email, 'invitation', host)` to send invitations to
|
||||
newly created/imported users.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend-architecture.md` (layering), `permissions.md`, and the auth slice
|
||||
(`src/services/auth.ts`, `src/api/controllers/auth.controller.ts`).
|
||||
60
backend/docs/error-handling.md
Normal file
60
backend/docs/error-handling.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Backend Error Handling
|
||||
|
||||
Backend errors use a single centralized path, mirroring the frontend
|
||||
(`frontend/docs/error-handling.md`). The goal: one place decides the HTTP status,
|
||||
the response body, and what gets logged — handlers and services only `throw`.
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. Route handlers are wrapped in `Helpers.wrapAsync`, whose `.catch(next)`
|
||||
forwards any thrown/rejected error to Express.
|
||||
2. With no per-router error middleware, the error bubbles to the single terminal
|
||||
handler registered last in `src/index.ts`: `errorHandler` from
|
||||
`src/middlewares/error-handler.ts`.
|
||||
3. `errorHandler` calls the pure `normalizeError(error)` to get
|
||||
`{ status, body, unexpected }`, logs unexpected (5xx) errors through the
|
||||
central `logger`, and sends `body` as JSON with `status`.
|
||||
4. Requests matching no route hit `notFoundHandler` (mounted on `/api`), which
|
||||
forwards a `NotFoundError` into the same `errorHandler`.
|
||||
|
||||
## Response shape
|
||||
|
||||
The JSON body is exactly what the frontend `ApiError` consumes:
|
||||
|
||||
```json
|
||||
{ "message": "human-readable text", "code": "machine.readable.key", "details": null }
|
||||
```
|
||||
|
||||
`code` and `details` are omitted when absent. The HTTP status carries 4xx/5xx;
|
||||
the frontend treats 401/403 as session expiry (`AuthExpiredError`).
|
||||
|
||||
## Error classes (`src/services/notifications/errors/`)
|
||||
|
||||
- `AppError(status, message, { code?, details? })` — base for all expected
|
||||
(operational) errors. `status >= 500` is treated as unexpected (logged).
|
||||
- `ValidationError` → 400, `UnauthorizedError` → 401, `ForbiddenError` → 403,
|
||||
`NotFoundError` → 404. Each resolves its message from
|
||||
`services/notifications/list.ts` and carries the notification key as `code`.
|
||||
|
||||
`normalizeError` also maps Sequelize `ValidationError`/`UniqueConstraintError`
|
||||
(and other `BaseError`s) to a client `400`, and any unknown/native error to a
|
||||
generic `500` whose body never leaks internals (the original is logged).
|
||||
|
||||
## Rules
|
||||
|
||||
- Throw an `AppError` subclass for expected failures; never `res.status().send()`
|
||||
a raw error or hand-format an error body in a handler.
|
||||
- Do not add per-router error middleware or scatter `console.error`/`console.log`;
|
||||
log through `@/services/logger` (`logger.error/warn/info`).
|
||||
- Pass native errors from external services through as-is (don't wrap), so the
|
||||
central handler surfaces them; only it decides the client-facing 500 body.
|
||||
- Boot/config validation (`config.ts`), CLI scripts (`db/umzug.ts`, `db/reset.ts`)
|
||||
and runtime invariants may still throw native `Error` — they crash the process
|
||||
or map to a generic 500, which is intended.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm test` runs `src/middlewares/error-handler.test.ts` (Node test runner via
|
||||
`tsx`), covering `AppError` subclasses, the Sequelize mapping, native errors, and
|
||||
non-`Error` thrown values. The build (`tsconfig.build.json`) excludes `*.test.ts`
|
||||
from `dist`.
|
||||
89
backend/docs/fee_plans.md
Normal file
89
backend/docs/fee_plans.md
Normal file
@ -0,0 +1,89 @@
|
||||
# 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`.
|
||||
98
backend/docs/file.md
Normal file
98
backend/docs/file.md
Normal file
@ -0,0 +1,98 @@
|
||||
# File Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
The file slice handles binary file upload and download. Uploaded files are stored either on
|
||||
local disk (development) or in Google Cloud Storage (production), selected at request time. The
|
||||
`file` table records file metadata (name, URLs, owning relation) and is written indirectly by the
|
||||
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`).
|
||||
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
|
||||
`downloadGCloud`, `deleteGCloud`, `initGCloud`).
|
||||
- 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).
|
||||
- Model: `src/db/models/file.ts`.
|
||||
- Shared used: `api/http/request.ts` (`paramStr`), `middlewares/upload.ts` (`processFile` multer
|
||||
wrapper), `shared/config.ts` (`config.uploadDir`, `config.gcloud.bucket`, `config.gcloud.hash`),
|
||||
`shared/errors/validation.ts` (`ValidationError`, in the DAL).
|
||||
|
||||
## 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`.
|
||||
- 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
|
||||
(`passport.authenticate('jwt')`). The folder is computed as `:table/:field`. Dispatches to
|
||||
`uploadGCloud` when `NODE_ENV === 'production'` or `NEXT_PUBLIC_BACK_API` is set, otherwise
|
||||
`uploadLocal`. The multipart form field name is `file`; the destination filename is taken from the
|
||||
request body field `filename`.
|
||||
- Local: no `req.currentUser` -> `403`; missing file -> `400`; missing `filename` -> `500`;
|
||||
success -> `200` (empty body). Errors -> `500` with the stringified error.
|
||||
- GCloud: missing file -> `400` `{ message }`; success -> `200`
|
||||
`{ message, url }` where `url` is the public `storage.googleapis.com` URL; errors -> `500`
|
||||
`{ message }`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Upload requires a valid JWT (route-level passport auth). The local upload path additionally
|
||||
rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied
|
||||
(`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not
|
||||
exercised from this endpoint.
|
||||
- Download has no authentication middleware and performs no ownership check; access is governed
|
||||
solely by knowing the `privateUrl`.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
None enforced in this slice. Neither upload nor download filters by organization; files are keyed
|
||||
by the `:table/:field` folder, the `filename`, and (for GCloud) the configured `hash` prefix. The
|
||||
`file` model has no `organizationId` column.
|
||||
|
||||
## Data Contract
|
||||
|
||||
`file` model columns: `id` (UUID, PK), `belongsTo` (string, nullable), `belongsToId` (UUID,
|
||||
nullable), `belongsToColumn` (string, nullable), `name` (string, required, non-empty),
|
||||
`sizeInBytes` (integer, nullable), `privateUrl` (string, nullable), `publicUrl` (string, required,
|
||||
non-empty), `createdAt`, `updatedAt`, `deletedAt` (paranoid soft delete), `createdById` (UUID,
|
||||
nullable), `updatedById` (UUID, nullable). Associations: `belongsTo` users as `createdBy` and
|
||||
`updatedBy`.
|
||||
|
||||
In the DAL (`replaceRelationFiles` / `_addFiles`), each new file requires `name` and `publicUrl`,
|
||||
otherwise `ValidationError('iam.errors.fileNameRequired')` is raised.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- Storage backend is chosen per request from `NODE_ENV` / `NEXT_PUBLIC_BACK_API`: GCloud in
|
||||
production, local disk otherwise.
|
||||
- The upload middleware (`src/middlewares/upload.ts`) uses multer memory storage with a 10 MB file
|
||||
size limit on the field named `file`. The controller passes `maxFileSize: 10 * 1024 * 1024` to
|
||||
`uploadLocal`, though that value is not consulted by `uploadLocal` (the limit comes from multer).
|
||||
- `services/file.ts` still operates directly on Express `Request`/`Response` (streaming
|
||||
upload/download). This is a documented architecture exception: the import-boundaries test
|
||||
(`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for
|
||||
`services/file.ts` and `services/auth.ts`.
|
||||
- `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy
|
||||
files), which is a DAL→BLL dependency. The same import-boundaries test caps DAL→BLL violations at
|
||||
one, and this file is the allowed one.
|
||||
- `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not
|
||||
present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows
|
||||
for inputs marked `new`.
|
||||
|
||||
## Tests
|
||||
|
||||
No dedicated `file` unit/e2e test exists. The architecture test
|
||||
`src/shared/architecture/import-boundaries.test.ts` references this slice by name in its
|
||||
BLL→HTTP and DAL→BLL debt-ceiling assertions.
|
||||
|
||||
## Related
|
||||
|
||||
- `search.md` (the other backend infrastructure slice).
|
||||
- Architecture contract: `backend/docs/backend-architecture.md`.
|
||||
@ -2,39 +2,66 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
`frame_entries` stores weekly F.R.A.M.E. focus entries per organization. The backend is the source of truth for persisted FRAME data.
|
||||
`frame_entries` stores weekly F.R.A.M.E. focus entries per organization. The backend is the
|
||||
source of truth for persisted FRAME data; the frontend never substitutes static samples for it.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/frame_entries.ts` (thin wiring; `GET /`, `POST /`, `PUT /:id`).
|
||||
- Controller: `src/api/controllers/frame_entries.controller.ts` (custom — not the CRUD factory).
|
||||
- Service (BLL): `src/services/frame_entries.ts`.
|
||||
- Repository (DAL): queries run through `db.frame_entries` inside the service (no separate
|
||||
`db/api/frame_entries.ts`).
|
||||
- Model: `src/db/models/frame_entries.ts`.
|
||||
- Shared used: `db/with-transaction.ts` (`withTransaction`), `services/shared/access.ts`
|
||||
(`getOrganizationIdOrGlobal`, `hasRoleAccess`), `shared/constants/pagination.ts` (`resolvePagination`),
|
||||
`shared/constants/frame.ts` (`FRAME_EDITOR_ROLE_NAMES`), `shared/errors/*`
|
||||
(`ForbiddenError`, `ValidationError`).
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
|
||||
- `GET /api/frame_entries`: returns `{ rows, count }` for the current user's organization.
|
||||
- `POST /api/frame_entries`: creates one entry and returns the created DTO.
|
||||
- `PUT /api/frame_entries/:id`: updates one entry inside the current user's organization and returns the updated DTO.
|
||||
- `GET /api/frame_entries` -> `200` `{ rows, count }` for the current user's organization
|
||||
(paginated via `resolvePagination`).
|
||||
- `POST /api/frame_entries` -> `201` the created entry DTO.
|
||||
- `PUT /api/frame_entries/:id` -> `200` the updated entry DTO (scoped to the org).
|
||||
|
||||
Request body for create/update is wrapped as `{ data: <FrameEntryInput> }`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- All authenticated users in the organization can read FRAME entries.
|
||||
- Editing is restricted to generated roles mapped to director or superintendent capabilities:
|
||||
- `Super Administrator`
|
||||
- `Administrator`
|
||||
- `Platform Owner`
|
||||
- `Tenant Director`
|
||||
- `Campus Manager`
|
||||
- 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
|
||||
`ForbiddenError`. Frontend may hide editing controls, but the backend check is authoritative.
|
||||
|
||||
The frontend may hide editing controls, but backend role checks remain authoritative.
|
||||
## Tenant Scope
|
||||
|
||||
- Organization is resolved via `getOrganizationIdOrGlobal`: users with `globalAccess` bypass the
|
||||
org filter and see/create entries across all organizations; regular users are bound to their
|
||||
organization.
|
||||
- `campusId` is optional; when omitted it defaults to the current staff profile's campus
|
||||
(`currentUser.staff_user[0].campusId`) when available, else `null`.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Required request fields:
|
||||
Required request fields (`REQUIRED_FIELDS`): `week_of`, `posted_date`, `formal`, `recognition`,
|
||||
`application`, `management`, `emotional`, `author`. Optional: `campusId`. Missing/invalid input
|
||||
raises `ValidationError`.
|
||||
|
||||
- `week_of`
|
||||
- `posted_date`
|
||||
- `formal`
|
||||
- `recognition`
|
||||
- `application`
|
||||
- `management`
|
||||
- `emotional`
|
||||
- `author`
|
||||
## Behavior / Notes
|
||||
|
||||
Tenant scope is assigned from the current authenticated user. `campusId` is optional and defaults to the current staff profile campus when available.
|
||||
- Create/update run inside `withTransaction`.
|
||||
- List is paginated with the shared defaults (`resolvePagination`).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `frame_entries` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/frame-integration.md`.
|
||||
- Related slices: `user-progress.md` (dashboard zone check-ins), `staff` (campus resolution).
|
||||
|
||||
82
backend/docs/grades.md
Normal file
82
backend/docs/grades.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Grades Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`grades` is the per-organization catalog of grade levels (e.g. year/grade tiers) used to
|
||||
classify classes and fee plans. It is a generic-CRUD slice assembled from the shared factories;
|
||||
the backend is the source of truth for these records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/grades.ts` — `createCrudRouter(controller, { permission: 'grades' })`.
|
||||
- Controller: `src/api/controllers/grades.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/grades.ts` — `createCrudService(DbApi, { notFoundCode: 'gradesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/grades.ts` (`GradesDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/grades.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/grades`, JWT + `${METHOD}_GRADES`
|
||||
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**), 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`, `code`, `description`, `sort_order`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('grades')`, deriving
|
||||
`READ_GRADES` / `CREATE_GRADES` / `UPDATE_GRADES` / `DELETE_GRADES` 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`, `code`, `description` (TEXT, nullable).
|
||||
- `sort_order` — INTEGER (nullable).
|
||||
- `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`.
|
||||
|
||||
List filters (`GradesFilter`): `id`, `name`, `code`, `description`, `sort_orderRange`, `active`,
|
||||
`organization` (`|`-separated ids), `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: `GradesFilter` 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: `classes`, `fee_plans`,
|
||||
`organizations`, `permissions.md`.
|
||||
86
backend/docs/guardians.md
Normal file
86
backend/docs/guardians.md
Normal file
@ -0,0 +1,86 @@
|
||||
# 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`.
|
||||
73
backend/docs/index.md
Normal file
73
backend/docs/index.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Backend Documentation Index
|
||||
|
||||
## Start Here
|
||||
|
||||
- Repository working rules: [`../../CLAUDE.md`](../../CLAUDE.md)
|
||||
- Backend architecture: [`backend-architecture.md`](backend-architecture.md)
|
||||
- Database schema: [`database-schema.md`](database-schema.md)
|
||||
- Error handling: [`error-handling.md`](error-handling.md)
|
||||
|
||||
Read the repository rules first, then use the backend architecture document as the default
|
||||
development contract for backend work. Every product slice and entity has its own document
|
||||
following the per-slice template (Purpose / Slice Files by layer / API / Access Rules /
|
||||
Tenant Scope / Data Contract / Behavior / Tests / Related).
|
||||
|
||||
## Architecture And Foundations
|
||||
|
||||
- [`backend-architecture.md`](backend-architecture.md): three-layer architecture
|
||||
(API -> BLL -> DAL), import direction, module-authoring factories.
|
||||
- [`shared-crud-factories.md`](shared-crud-factories.md): the CRUD service/controller/router
|
||||
factories, repository helpers, and shared service helpers every generic-CRUD slice is built from.
|
||||
- [`database-schema.md`](database-schema.md): generated table/column/relation reference.
|
||||
- [`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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Product Feature Slices
|
||||
|
||||
- [`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)
|
||||
- [`safety-quiz-results.md`](safety-quiz-results.md)
|
||||
- [`staff-attendance.md`](staff-attendance.md)
|
||||
- [`user-progress.md`](user-progress.md)
|
||||
- [`walkthrough-checkins.md`](walkthrough-checkins.md)
|
||||
|
||||
## Generic CRUD Entity Slices
|
||||
|
||||
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).
|
||||
- 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).
|
||||
|
||||
## Infrastructure Slices
|
||||
|
||||
- [`file.md`](file.md): upload/download, storage backend, ownership notes.
|
||||
- [`search.md`](search.md): cross-entity search endpoint.
|
||||
- [`email.md`](email.md): transactional email senders and HTML templates.
|
||||
- [`shared-crud-factories.md`](shared-crud-factories.md): shared CRUD building blocks.
|
||||
- [`migrations-and-seeders.md`](migrations-and-seeders.md): Umzug runner and conventions.
|
||||
93
backend/docs/invoices.md
Normal file
93
backend/docs/invoices.md
Normal file
@ -0,0 +1,93 @@
|
||||
# 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`.
|
||||
91
backend/docs/message_recipients.md
Normal file
91
backend/docs/message_recipients.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Message Recipients Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`message_recipients` is the per-organization store of per-recipient delivery records for messages
|
||||
(one row per recipient, with delivery and read status). It is a generic-CRUD slice assembled from
|
||||
the shared factories; the backend is the source of truth for these records. This is the
|
||||
generic-CRUD slice for the `message_recipients` table; the higher-level parent-messaging workflow
|
||||
is documented separately in `communications.md`.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/message_recipients.ts` — `createCrudRouter(controller, { permission: 'message_recipients' })`.
|
||||
- Controller: `src/api/controllers/message_recipients.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/message_recipients.ts` — `createCrudService(DbApi, { notFoundCode: 'message_recipientsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/message_recipients.ts` (`Message_recipientsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/message_recipients.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/message_recipients`, JWT +
|
||||
`${METHOD}_MESSAGE_RECIPIENTS` 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
|
||||
`recipient_label`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `recipient_label`, `destination`, `delivered_at`, `read_at`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('message_recipients')`,
|
||||
deriving `READ_MESSAGE_RECIPIENTS` / `CREATE_MESSAGE_RECIPIENTS` /
|
||||
`UPDATE_MESSAGE_RECIPIENTS` / `DELETE_MESSAGE_RECIPIENTS` 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), `recipient_label`, `destination` (TEXT, nullable).
|
||||
- `recipient_type` — ENUM `user` | `student` | `guardian`.
|
||||
- `delivery_status` — ENUM `pending` | `sent` | `delivered` | `failed` | `read`.
|
||||
- `delivered_at`, `read_at` — DATE.
|
||||
- `importHash` (unique), `organizationId`, `messageId`, `createdById`, `updatedById`, timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, message, createdBy/updatedBy (users). `findBy`/`GET /:id`
|
||||
eager-load organization and message in a single `Promise.all`.
|
||||
|
||||
List filters (`MessageRecipientsFilter`): `id`, `recipient_label`, `destination`,
|
||||
`delivered_atRange`, `read_atRange`, `recipient_type`, `delivery_status`, `message` (id or
|
||||
subject, `|`-separated), `organization`, `createdAtRange`, plus `field`/`sort` ordering and
|
||||
`limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- This slice has no file relations.
|
||||
- `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: `MessageRecipientsFilter` 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`; parent-messaging workflow:
|
||||
`communications.md`; related slices: `messages`, `permissions.md`.
|
||||
94
backend/docs/messages.md
Normal file
94
backend/docs/messages.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Messages Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`messages` is the per-organization store of broadcast/announcement messages (subject, body,
|
||||
channel, audience). It is a generic-CRUD slice assembled from the shared factories; the backend
|
||||
is the source of truth for message records. This is the generic-CRUD slice for the `messages`
|
||||
table; the higher-level parent-messaging workflow is documented separately in `communications.md`.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/messages.ts` — `createCrudRouter(controller, { permission: 'messages' })`.
|
||||
- Controller: `src/api/controllers/messages.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/messages.ts` — `createCrudService(DbApi, { notFoundCode: 'messagesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/messages.ts` (`MessagesDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/messages.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/messages`, JWT + `${METHOD}_MESSAGES`
|
||||
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
|
||||
`subject`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `subject`, `body`, `sent_at`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('messages')`, deriving
|
||||
`READ_MESSAGES` / `CREATE_MESSAGES` / `UPDATE_MESSAGES` / `DELETE_MESSAGES` 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), `subject`, `body` (TEXT, nullable).
|
||||
- `channel` — ENUM `in_app` | `email` | `sms`.
|
||||
- `audience` — ENUM `all_org` | `campus` | `class` | `staff` | `students` | `guardians` | `custom`.
|
||||
- `sent_at` — DATE.
|
||||
- `status` — ENUM `draft` | `scheduled` | `sent` | `failed`.
|
||||
- `importHash` (unique), `campusId`, `organizationId`, `sent_byId`, `createdById`, `updatedById`,
|
||||
timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, campus, sent_by (users), createdBy/updatedBy (users);
|
||||
`hasMany` `message_recipients_message` (message_recipients); `hasMany` file as `attachments`
|
||||
(scoped relation). `findBy`/`GET /:id` eager-load `message_recipients_message`, organization,
|
||||
campus, sent_by, attachments in a single `Promise.all`.
|
||||
|
||||
List filters (`MessagesFilter`): `id`, `subject`, `body`, `sent_atRange`, `channel`, `audience`,
|
||||
`status`, `campus` (id or name, `|`-separated), `sent_by` (id or firstName, `|`-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: `MessagesFilter` 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`; parent-messaging workflow:
|
||||
`communications.md`; related slices: `message_recipients`, `campuses`, `file.md`,
|
||||
`permissions.md`.
|
||||
60
backend/docs/migrations-and-seeders.md
Normal file
60
backend/docs/migrations-and-seeders.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Migrations And Seeders Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
Schema creation and reference-data seeding run through a small Umzug runner (it replaced
|
||||
sequelize-cli during the TypeScript/ESM migration). This doc covers the developer mechanism:
|
||||
the runner, file conventions, and how to add a migration or seeder. For VM/PM2 operational use
|
||||
(when migrate/seed run on deploy, `db:reset` recovery), see `../../docs/deployment-vm.md`.
|
||||
|
||||
## Files
|
||||
|
||||
- Runner: `src/db/umzug.ts` — builds two `Umzug` instances (`migrator`, `seeder`) and a small CLI.
|
||||
- Reset: `src/db/reset.ts` — drops every table in the `public` schema, then re-runs
|
||||
`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/`).
|
||||
|
||||
## Mechanism
|
||||
|
||||
- `migrator` globs `migrations/*.{ts,js}`; history is tracked in the default `SequelizeMeta`
|
||||
table via `SequelizeStorage`.
|
||||
- `seeder` globs `seeders/*.{ts,js}`; history is tracked in a separate `SequelizeData` table.
|
||||
- Each file is ESM TypeScript with a default export `{ up, down }`, each taking
|
||||
`(queryInterface, Sequelize)`. The runner accepts either a `default` export or top-level
|
||||
`up`/`down`.
|
||||
- Tracked names strip the `.ts`/`.js` extension, so history is stable whether the runner is
|
||||
executed via `tsx` (dev, `.ts`) or compiled (`prod`, `dist/.../*.js`).
|
||||
- The `glob` accepts both `.ts` and `.js`, so already-applied entries are not re-run after a
|
||||
build.
|
||||
|
||||
## CLI And Scripts
|
||||
|
||||
`src/db/umzug.ts` exposes: `migrate:up`, `migrate:down`, `migrate:pending`, `seed:up`,
|
||||
`seed:down`. npm scripts wrap them:
|
||||
|
||||
- Dev (via `tsx`): `db:migrate` (`migrate:up`), `db:migrate:undo` (`migrate:down`),
|
||||
`db:migrate:pending`, `db:seed` (`seed:up`), `db:seed:undo` (`seed:down`),
|
||||
`db:reset` (`tsx src/db/reset.ts`).
|
||||
- Prod (compiled, no `tsx`): `db:migrate:prod` (`node dist/db/umzug.js migrate:up`),
|
||||
`db:seed:prod` (`node dist/db/umzug.js seed:up`).
|
||||
|
||||
## Authoring A New Migration / Seeder
|
||||
|
||||
1. Add `src/db/migrations/<timestamp>-<name>.ts` (or `seeders/...`) exporting
|
||||
`export default { up, down }` with typed `(queryInterface, Sequelize)` signatures.
|
||||
2. Run `npm run db:migrate` (or `db:seed`) in dev; verify with `db:migrate:pending`.
|
||||
3. Regenerate `database-schema.md` after any schema change (it is generated from the models).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- `database-schema.md` (the generated schema reference), `backend-architecture.md` (DAL layer),
|
||||
`../../docs/deployment-vm.md` (operational migrate/seed on the VM).
|
||||
100
backend/docs/organizations.md
Normal file
100
backend/docs/organizations.md
Normal file
@ -0,0 +1,100 @@
|
||||
# Organizations Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`organizations` is the tenant table itself — each row is a tenant org record that every other
|
||||
per-organization slice references via `organizationId`. It is a generic-CRUD slice assembled from
|
||||
the shared factories; the backend is the source of truth for organization records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/organizations.ts` — `createCrudRouter(controller, { permission: 'organizations' })`.
|
||||
- Controller: `src/api/controllers/organizations.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/organizations.ts` — `createCrudService(DbApi, { notFoundCode: 'organizationsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/organizations.ts` (`OrganizationsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/organizations.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/organizations`, JWT + `${METHOD}_ORGANIZATIONS`
|
||||
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`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('organizations')`, deriving
|
||||
`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`).
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
This entity **is** the tenant table, so its tenant scoping is unusual relative to the other
|
||||
slices. The model has no `organizationId` column of its own; rows are tenants, identified by `id`.
|
||||
|
||||
- `findAll` still applies the generic scoping pattern: it sets `where.organizationId` to
|
||||
`currentUser.organizationId`, then a `globalAccess` role deletes that key. Because the
|
||||
`organizations` table has no `organizationId` column, a non-global user's query would filter on
|
||||
a non-existent column; only `globalAccess` users reliably list organizations.
|
||||
- `create`/`update` do **not** set or reassign any organization (no `setOrganization`); they only
|
||||
persist `name` (plus `createdById`/`updatedById`).
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`paranoid`, soft-delete via `deletedAt`):
|
||||
|
||||
- `id` (UUID PK).
|
||||
- `name` — TEXT, nullable.
|
||||
- `importHash` (unique), `createdById`, `updatedById`, timestamps.
|
||||
|
||||
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`,
|
||||
`class_subjects_organization`, `timetables_organization`, `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`. `findBy`/`GET /:id` eager-load all of these in a single `Promise.all`.
|
||||
|
||||
List filters (`OrganizationsFilter`): `id`, `name`, `createdAtRange`, plus `field`/`sort` ordering
|
||||
and `limit`/`page` pagination. `findAll` runs no `include`, so list rows carry no eager
|
||||
associations.
|
||||
|
||||
## 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: `OrganizationsFilter` 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`; tenant scoping: `permissions.md`. Every
|
||||
per-organization slice references this table via `organizationId` (e.g. `students`, `guardians`,
|
||||
`staff`, `campuses`).
|
||||
91
backend/docs/payments.md
Normal file
91
backend/docs/payments.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 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`.
|
||||
116
backend/docs/permissions.md
Normal file
116
backend/docs/permissions.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Permissions Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`permissions` is the catalog of named permission strings that authorize API access. Each
|
||||
permission is a single `name` (e.g. `CREATE_USERS`, `READ_DOCUMENTS`). Permissions are attached to
|
||||
roles (a role's `permissions`) and to individual users (a user's `custom_permissions`); the
|
||||
authorization middleware checks them per request. This slice manages the permission catalog itself
|
||||
(CRUD); the consumption of permission names happens in `middlewares/check-permissions.ts`.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/permissions.ts` (CRUD plus `bulk-import`, `count`, `autocomplete`,
|
||||
`deleteByIds`; applies `checkCrudPermissions('permissions')` to every route).
|
||||
- Controller: `src/api/controllers/permissions.controller.ts` (CSV export, file upload for bulk
|
||||
import).
|
||||
- Service (BLL): `src/services/permissions.ts` (transactional writes; CSV buffer parsing).
|
||||
- Repository (DAL): `src/db/api/permissions.ts`.
|
||||
- Model: `src/db/models/permissions.ts`.
|
||||
- Consumer middleware: `src/middlewares/check-permissions.ts` (how permission names are read and
|
||||
enforced).
|
||||
- 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`),
|
||||
`middlewares/upload.ts` (`processFile`), `db/api/roles.ts` (`RolesDBApi`, used by the
|
||||
middleware), `shared/object.ts` (`isRecord`), `shared/logger.ts`,
|
||||
`shared/errors/validation.ts`.
|
||||
|
||||
## API
|
||||
|
||||
All routes are mounted under `/api/permissions` and require JWT authentication (`src/index.ts`).
|
||||
Every route passes `checkCrudPermissions('permissions')`, requiring `${METHOD}_PERMISSIONS`.
|
||||
|
||||
- `POST /api/permissions` -> `200` `true`. Request body: `{ data: <PermissionInput> }`.
|
||||
- `POST /api/permissions/bulk-import` -> `200` `true`. Multipart CSV upload. Missing file raises
|
||||
`ValidationError('importer.errors.invalidFileEmpty')`.
|
||||
- `PUT /api/permissions/:id` -> `200` `true`. The controller calls
|
||||
`Service.update(req.body.data, req.body.id, ...)` (reads `req.body.id`, not `req.params.id`).
|
||||
- `DELETE /api/permissions/:id` -> `200` `true`.
|
||||
- `POST /api/permissions/deleteByIds` -> `200` `true`. Request body: `{ data: string[] }`.
|
||||
- `GET /api/permissions` -> `200` `{ rows, count }`. When `?filetype=csv`, responds with a CSV
|
||||
attachment of fields `id, name`.
|
||||
- `GET /api/permissions/count` -> `200` `{ rows: [], count }`.
|
||||
- `GET /api/permissions/autocomplete` -> `200` array of `{ id, label }` (label is the permission
|
||||
`name`, ordered by `name ASC`).
|
||||
- `GET /api/permissions/:id` -> `200`, a single plain record.
|
||||
|
||||
## Access Rules
|
||||
|
||||
CRUD on the permissions catalog is gated solely by `checkCrudPermissions('permissions')`
|
||||
(`${METHOD}_PERMISSIONS`). There is no tenant scope or additional role-name gate inside the
|
||||
service or repository.
|
||||
|
||||
## How permission names are consumed (`check-permissions.ts`)
|
||||
|
||||
`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
|
||||
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`.
|
||||
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
|
||||
`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
|
||||
granted when that list `includes(permission)`.
|
||||
|
||||
On denial the middleware logs the role name and the denied permission and calls
|
||||
`next(new ValidationError('auth.forbidden'))`. A role object lacking both a `permissions` array and
|
||||
a `getPermissions()` method, or a missing/unfetchable `Public` role, surfaces an Internal Server
|
||||
Error via `next(new Error(...))`.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
None. The permission catalog is global; `findAll` applies no organization filter.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`src/db/models/permissions.ts`): `id` (UUID PK), `name` (text), `importHash`
|
||||
(unique), `createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete), `createdById`,
|
||||
`updatedById`. Associations: `belongsTo users` as `createdBy`/`updatedBy` only. The model itself
|
||||
declares no association to roles or users for the permission grants; those join tables are declared
|
||||
on the `roles` and `users` models (e.g. `users.belongsToMany(permissions)` through
|
||||
`usersCustom_permissionsPermissions`).
|
||||
|
||||
List filters (`findAll`): `id` (uuid), `name` (ILIKE), `active`, `createdAtRange`, plus
|
||||
`field`/`sort` (default `createdAt desc`) and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
|
||||
- `update` raises `ValidationError('permissionsNotFound')` when the record does not exist.
|
||||
- Bulk import parses the uploaded CSV (`csv-parser`), then `bulkCreate`s with
|
||||
`ignoreDuplicates: true` and staggered `createdAt` (`BULK_IMPORT_TIMESTAMP_STEP_MS`).
|
||||
- The `Public` role permission cache in `check-permissions.ts` is loaded at application startup;
|
||||
a startup failure to load it is logged but does not stop boot (the synchronous fallback covers
|
||||
per-request misses).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `permissions` unit/e2e test under `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Backend slices: the `roles` entity (a role's `permissions` provide the effective-role grants)
|
||||
and `users.md` (a user's `custom_permissions` and assigned `app_role`, eager-loaded in
|
||||
`UsersDBApi.findBy` for per-request authorization).
|
||||
- `auth-profile.md` (the profile DTO carries `app_role_permissions` and `custom_permissions`, the
|
||||
same permission names enforced here).
|
||||
@ -2,43 +2,88 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
The personality quiz results API stores each authenticated staff user's current EI/personality quiz result and exposes aggregate distribution data for leadership reporting.
|
||||
`personality_quiz_results` stores each authenticated tenant user's current personality quiz
|
||||
result (one row per user per organization) and exposes an aggregate distribution of personality
|
||||
types for leadership reporting. The backend owns tenant scope, user ownership, the saved
|
||||
personality type, and the answer snapshot. It does not write to staff profile records.
|
||||
|
||||
The workflow uses a dedicated tenant-owned table:
|
||||
## Slice Files (by layer)
|
||||
|
||||
- `personality_quiz_results`
|
||||
|
||||
It does not update staff profile records. Staff profile extension should be handled as a separate schema decision if the product later needs personality type on the staff profile itself.
|
||||
- Route: `src/routes/personality_quiz_results.ts` (thin wiring; `GET /me`, `PUT /me`,
|
||||
`GET /distribution`).
|
||||
- Controller: `src/api/controllers/personality_quiz_results.controller.ts` (custom — not the CRUD
|
||||
factory).
|
||||
- Service (BLL): `src/services/personality_quiz_results.ts`.
|
||||
- Repository (DAL): queries run through `db.personality_quiz_results` inside the service (no
|
||||
separate `db/api/personality_quiz_results.ts`).
|
||||
- Model: `src/db/models/personality_quiz_results.ts`.
|
||||
- Shared used: `db/with-transaction.ts` (`withTransaction`); `services/shared/access.ts`
|
||||
(`getOrganizationIdOrGlobal`, `getCampusId`, `assertAuthenticatedTenantUser`, `hasRoleAccess`);
|
||||
`shared/constants/personality.ts` (`PERSONALITY_REPORT_ROLE_NAMES`); `shared/errors/*`
|
||||
(`ForbiddenError`, `ValidationError`).
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
All routes require JWT authentication. Base path mounted at `/api/personality_quiz_results`.
|
||||
|
||||
- `GET /api/personality_quiz_results/me`: returns the current user's saved result or `null`.
|
||||
- `PUT /api/personality_quiz_results/me`: creates or updates the current user's saved result.
|
||||
- `GET /api/personality_quiz_results/distribution`: returns aggregate counts by personality type for authorized report roles.
|
||||
- `GET /api/personality_quiz_results/distribution?campusId=<campusId>`: filters aggregate counts by campus when provided.
|
||||
- `GET /api/personality_quiz_results/me` -> `200`. Returns the current user's saved result DTO
|
||||
(most recently updated), or `null` if none exists.
|
||||
- `PUT /api/personality_quiz_results/me` -> `200`. Request body wrapped as
|
||||
`{ data: { personality_type, quiz_answers } }`. Creates or updates the current user's result and
|
||||
returns the saved DTO.
|
||||
- `GET /api/personality_quiz_results/distribution` -> `200`. Optional query `campusId`. Returns
|
||||
`{ rows: [{ type, count }], count }` (number of distinct personality types). Restricted to report
|
||||
roles.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Authenticated tenant users can read and update only their own result.
|
||||
- Director, superintendent, and mapped backend leadership roles can read aggregate distributions.
|
||||
- Distribution endpoints return counts only. They do not expose individual staff names or individual answers.
|
||||
- Organization, campus, user, creator, and updater fields are derived from the authenticated backend user.
|
||||
- `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
|
||||
`globalAccess`, enforced by `hasRoleAccess`; otherwise `ForbiddenError`. The distribution
|
||||
response contains only `type` and `count` per group — no individual names or answers.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
|
||||
filter and can see their results across organizations; regular users are bound to their org.
|
||||
- On upsert, `campusId` is set from `getCampusId` (the current staff profile's campus, else the
|
||||
user's `campusId`, else `null`); `userId`, `createdById`, `updatedById` come from the current
|
||||
user.
|
||||
- `distribution` filters by organization via `getOrganizationIdOrGlobal` (global users see all
|
||||
orgs) and, when a `campusId` query value is provided, additionally by that campus.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Result mutation fields:
|
||||
- Mutation input (`PUT /me`): `personality_type` (non-empty string) and `quiz_answers` (a non-array
|
||||
object whose values are all non-empty strings). Invalid input raises `ValidationError`.
|
||||
- On save, `personality_type` is trimmed and upper-cased; `completed_at` is set to the current
|
||||
time.
|
||||
- DTO fields: `id`, `personality_type`, `quiz_answers`, `completed_at`, `organizationId`,
|
||||
`campusId`, `userId`, `createdById`, `updatedById`, `createdAt`, `updatedAt`.
|
||||
- Model columns: `personality_type` (TEXT, not null), `quiz_answers` (JSONB, not null),
|
||||
`completed_at` (DATE, not null), `importHash` (unique), plus tenant/audit UUID columns
|
||||
(`organizationId`, `campusId`, `userId`, `createdById`, `updatedById`, all nullable). The model
|
||||
is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`.
|
||||
- Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users (`user`,
|
||||
`createdBy`, `updatedBy`).
|
||||
|
||||
- `personalityType`
|
||||
- `quizAnswers`
|
||||
## Behavior / Notes
|
||||
|
||||
The backend validates that `personalityType` is present and that `quizAnswers` is an object. The frontend sends answer maps through the typed API layer; the backend stores them as JSON.
|
||||
- `upsertCurrentUserResult` runs inside `withTransaction`: it looks up the existing row by
|
||||
`organizationId` + `userId` and updates it, otherwise creates a new one.
|
||||
- `getCurrentUserResult` orders by `updatedAt` desc and returns the first match.
|
||||
- `distribution` groups by `personality_type` with a `COUNT(id)` aggregate, ordered by count desc;
|
||||
`count` in the response is the number of distinct types returned.
|
||||
|
||||
## Files
|
||||
## Tests
|
||||
|
||||
- `backend/src/constants/personality.js`
|
||||
- `backend/src/db/models/personality_quiz_results.js`
|
||||
- `backend/src/db/migrations/20260608005000-create-personality-quiz-results.js`
|
||||
- `backend/src/services/personality_quiz_results.js`
|
||||
- `backend/src/routes/personality_quiz_results.js`
|
||||
None yet (no `personality_quiz_results` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/personality-integration.md`, `frontend/docs/personality-catalog.md`.
|
||||
- Related slices: `safety-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md` (similar
|
||||
per-user tenant-scoped result/progress pattern).
|
||||
|
||||
120
backend/docs/roles.md
Normal file
120
backend/docs/roles.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Roles Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`roles` is the catalog of access roles. It is wired through the shared generic-CRUD router,
|
||||
controller, and service factories, but its repository is **custom**: a role is not a plain record,
|
||||
it carries a many-to-many link to `permissions`, and the repository manages that link on every
|
||||
create/update and eager-loads it on reads. Roles are what `users` reference as their app role, so
|
||||
this slice underpins the permission checks documented in `permissions.md` and `auth-profile.md`.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/roles.ts` — `createCrudRouter(controller, { permission: 'roles' })`.
|
||||
- Controller: `src/api/controllers/roles.controller.ts` — `createCrudController(service, { csvFields: ['id', 'name'] })`.
|
||||
- Service (BLL): `src/services/roles.ts` — `createCrudService(DbApi, { notFoundCode: 'rolesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/roles.ts` (`RolesDBApi`) — custom; see Behavior. `remove`/`deleteByIds`
|
||||
delegate to `db/api/shared/repository.ts`; `create`/`update`/`findBy`/`findAll`/`findAllAutocomplete`
|
||||
are entity-specific (autocomplete is implemented inline, not via the shared `autocompleteByField`).
|
||||
- Model: `src/db/models/roles.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` — `removeRecord`, `deleteRecordsByIds`),
|
||||
`shared/constants/pagination.ts` (`resolvePagination`), `shared/config` (`config.roles.super_admin`),
|
||||
`db/utils` (`uuid`, `ilike`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/roles`, JWT + `${METHOD}_ROLES` 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`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('roles')`, deriving
|
||||
`READ_ROLES` / `CREATE_ROLES` / `UPDATE_ROLES` / `DELETE_ROLES` per HTTP method.
|
||||
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
|
||||
- Visibility gate on the super-admin role: in both `findAll` and `findAllAutocomplete`, when the
|
||||
caller does **not** have `globalAccess`, the where clause is reset to
|
||||
`name <> config.roles.super_admin`, so non-global callers never see the super-admin role.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- `roles` has no `organizationId` column; the catalog is not tenant-scoped. The only access
|
||||
partition is the super-admin visibility gate above (driven by `globalAccess`, not by organization).
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
|
||||
|
||||
- `id` (UUID PK), `name` (TEXT, nullable), `globalAccess` (BOOLEAN, not null, default `false`),
|
||||
`importHash` (STRING(255), unique, nullable), `createdById`, `updatedById`,
|
||||
`createdAt` / `updatedAt` / `deletedAt`.
|
||||
|
||||
Associations:
|
||||
|
||||
- `belongsToMany` `permissions` as `permissions` — through table `rolesPermissionsPermissions`,
|
||||
foreign key `roles_permissionsId`, `constraints: false`.
|
||||
- `belongsToMany` `permissions` as `permissions_filter` — same through table and foreign key
|
||||
(`rolesPermissionsPermissions` / `roles_permissionsId`), used only for filtered list queries.
|
||||
- `hasMany` `users` as `users_app_role` (foreign key `app_roleId`) — the users whose app role is
|
||||
this role.
|
||||
- `belongsTo` `users` as `createdBy` / `updatedBy`.
|
||||
|
||||
`findBy` (backing `GET /:id`) returns the plain role plus two eager-loaded keys fetched in a single
|
||||
`Promise.all`: `users_app_role` (from `getUsers_app_role`) and `permissions` (from `getPermissions`).
|
||||
|
||||
List filters (`RolesFilter`): `id`, `name` (ilike), `active`, `globalAccess`, `permissions`
|
||||
(`|`-separated; matched by permission id or permission name ilike via the `permissions_filter`
|
||||
include), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. Default order
|
||||
is `createdAt desc`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- Role <-> permission linkage is the defining custom behavior. `create` builds the role row, then
|
||||
calls `roles.setPermissions(data.permissions || [])` to replace the through-table rows. `update`
|
||||
only touches the through table when `data.permissions !== undefined`, calling
|
||||
`roles.setPermissions(data.permissions)` (a full replace of the role's permission set). `findBy`
|
||||
reads them back with `roles.getPermissions()`. So a role's permissions are passed as a flat
|
||||
`string[]` of permission ids on create/update and returned as the eager `permissions` array on
|
||||
read — this is the main way this slice differs from a plain CRUD entity, whose repository only
|
||||
manages its own columns.
|
||||
- `findAll` always eager-loads `permissions` (`required: false`). When the `permissions` filter is
|
||||
present it additionally joins `permissions_filter` (the second alias over the same through table)
|
||||
with an `Op.or` of permission id `Op.in` and permission name ilike, and the list uses
|
||||
`distinct: true` to avoid row multiplication from the join.
|
||||
- The super-admin gate (see Access Rules) **replaces** the accumulated where clause rather than
|
||||
merging into it, so for a non-global caller other filters on the same query are overridden by the
|
||||
`name <> super_admin` condition (kept here for source accuracy).
|
||||
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order; it
|
||||
does **not** set permissions (only `create`/`update` do).
|
||||
- List pagination uses the shared `resolvePagination` defaults; `findAllAutocomplete` orders by
|
||||
`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.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`.
|
||||
- Related slices: `permissions.md` (the linked entity), `users.md` (references a role via
|
||||
`app_roleId`), `auth-profile.md` (how the signed-in user's role/permissions are resolved).
|
||||
@ -2,31 +2,82 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
`safety_quiz_results` stores weekly de-escalation/QBS quiz submissions per authenticated staff user. The backend owns tenant scope, user ownership, user display name, and role snapshot.
|
||||
`safety_quiz_results` stores weekly safety/de-escalation quiz submissions per authenticated staff
|
||||
user. The backend owns tenant scope, user ownership, the user display-name snapshot, the product
|
||||
role snapshot, and persistence. Each submission is an append (create) — there is no update path.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/safety_quiz_results.ts` (thin wiring; `GET /`, `POST /`).
|
||||
- Controller: `src/api/controllers/safety_quiz_results.controller.ts` (custom — not the CRUD
|
||||
factory).
|
||||
- Service (BLL): `src/services/safety_quiz_results.ts`.
|
||||
- Repository (DAL): queries run through `db.safety_quiz_results` inside the service (no separate
|
||||
`db/api/safety_quiz_results.ts`).
|
||||
- Model: `src/db/models/safety_quiz_results.ts`.
|
||||
- Shared used: `db/with-transaction.ts` (`withTransaction`); `shared/constants/pagination.ts`
|
||||
(`resolvePagination`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`, `getCampusId`,
|
||||
`assertAuthenticatedTenantUser`, `hasRoleAccess`, `getDisplayName`); `shared/constants/roles.ts`
|
||||
(`GENERATED_ROLE_TO_PRODUCT_ROLE`, `PRODUCT_ROLE_VALUES`); `shared/constants/safety-quiz.ts`
|
||||
(`SAFETY_QUIZ_REPORT_ROLE_NAMES`); `shared/errors/validation.ts` (`ValidationError`).
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
All routes require JWT authentication. Base path mounted at `/api/safety_quiz_results`.
|
||||
|
||||
- `GET /api/safety_quiz_results`: returns quiz results visible to the current user.
|
||||
- `GET /api/safety_quiz_results?week_of=<week>`: filters visible results by week.
|
||||
- `POST /api/safety_quiz_results`: saves one quiz result for the current user.
|
||||
- `GET /api/safety_quiz_results` -> `200` `{ rows, count }`. Optional query `week_of`, plus
|
||||
`limit` / `page` (paginated via `resolvePagination`). Returns results visible to the current user
|
||||
(see Access Rules), ordered by `completed_at` desc.
|
||||
- `POST /api/safety_quiz_results` -> `201`. Request body wrapped as `{ data: <SafetyQuizInput> }`.
|
||||
Returns the created result DTO.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Staff users can create results for themselves.
|
||||
- Staff users can read their own results.
|
||||
- Director/superintendent-capable generated roles can read organization-level results for compliance views.
|
||||
- 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
|
||||
`globalAccess` (via `hasRoleAccess`) see all org-level results; everyone else sees only their own
|
||||
rows (filtered by `userId`).
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
|
||||
filter and see results across all organizations; regular users are bound to their organization.
|
||||
- On create, `campusId` is set from `getCampusId`; `userId`, `createdById`, `updatedById` come from
|
||||
the current user.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Required mutation fields:
|
||||
- Mutation input (`SafetyQuizInput`): `quiz_id`, `quiz_title`, `week_of` (non-empty strings);
|
||||
`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
|
||||
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`,
|
||||
`updatedAt`.
|
||||
- Model columns: `quiz_id`, `quiz_title`, `week_of`, `user_name`, `user_role` (all TEXT, not null);
|
||||
`score`, `total_questions` (INTEGER, not null); `answers` (JSONB, not null); `completed_at`
|
||||
(DATE, not null); `importHash` (unique); plus tenant/audit UUID columns (`organizationId`,
|
||||
`campusId`, `userId`, `createdById`, `updatedById`, all nullable). The model is `paranoid` (soft
|
||||
delete via `deletedAt`) and uses `freezeTableName`.
|
||||
- Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users (`user`,
|
||||
`createdBy`, `updatedBy`).
|
||||
|
||||
- `quiz_id`
|
||||
- `quiz_title`
|
||||
- `week_of`
|
||||
- `score`
|
||||
- `total_questions`
|
||||
- `answers`
|
||||
## Behavior / Notes
|
||||
|
||||
The frontend does not send user names or roles for ownership. The backend fills `user_name`, `user_role`, `organizationId`, `campusId`, and `userId` from the authenticated user.
|
||||
- `create` runs inside `withTransaction`; trimmed string fields are persisted.
|
||||
- `list` is paginated with shared defaults (`resolvePagination`).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `safety_quiz_results` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/safety-quiz-integration.md`.
|
||||
- Related slices: `personality-quiz-results.md`, `walkthrough-checkins.md`, `user-progress.md`.
|
||||
|
||||
88
backend/docs/search.md
Normal file
88
backend/docs/search.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Search Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
The search slice provides a single cross-entity text search endpoint. It scans a fixed set of
|
||||
tables for a query string across predefined text and numeric columns, applies per-table permission
|
||||
and tenant filtering, and returns the matching records annotated with which columns matched.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/search.ts` (thin wiring; `POST /`, guarded by
|
||||
`permissions.checkCrudPermissions('search')`).
|
||||
- Controller: `src/api/controllers/search.controller.ts` (custom — `search`).
|
||||
- Service (BLL): `src/services/search.ts` (`SearchService.search`; queries run through
|
||||
`db.sequelize.models[tableName]` inside the service, no separate `db/api/search.ts`).
|
||||
- Repository (DAL): queries via `db.sequelize.models[<table>]` in the service.
|
||||
- Shared used: `api/http/request.ts` (`wrapAsync`), `middlewares/check-permissions.ts`
|
||||
(`permissions.checkCrudPermissions`), `shared/logger.ts`, `shared/errors/validation.ts`
|
||||
(`ValidationError`), `db/models` (`db`).
|
||||
|
||||
## API
|
||||
|
||||
- `POST /api/search` -> `200` JSON array of matched records. Requires authentication (the route is
|
||||
mounted with the `authenticated` middleware in `src/index.ts`) and the `search` CRUD permission
|
||||
(`checkCrudPermissions('search')`).
|
||||
- Request body: `{ searchQuery, organizationId }`.
|
||||
- Missing/empty `searchQuery` -> `400` `{ error: 'Please enter a search query' }`.
|
||||
- On service error -> `500` `{ error: 'Internal Server Error' }` (logged via `logger.error`).
|
||||
- Success -> `200` with the array returned by `SearchService.search`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- The route requires the `search` CRUD permission via `checkCrudPermissions('search')`.
|
||||
- `globalAccess` is read from `req.currentUser.app_role.globalAccess` (default `false`).
|
||||
- Per table, the service calls `checkPermissions('READ_<TABLE>')`: the user must have a matching
|
||||
custom permission or an app-role permission named `READ_<TABLE>` (uppercased table name);
|
||||
tables the user cannot read are skipped. A missing `currentUser` raises
|
||||
`ValidationError('auth.unauthorized')`; a present user with no `app_role` raises
|
||||
`ValidationError('auth.forbidden')`.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
For each table other than `organizations`, when `globalAccess` is false and an `organizationId` is
|
||||
supplied, the query adds `organizationId = <organizationId>` to the where clause. With
|
||||
`globalAccess` true, or for the `organizations` table, no organization filter is applied.
|
||||
|
||||
## Data Contract
|
||||
|
||||
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`
|
||||
(name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks);
|
||||
`fee_plans` (name, notes); `invoices` (invoice_number, notes); `payments` (receipt_number,
|
||||
reference_code, notes); `assessments` (name, instructions); `assessment_results` (remarks);
|
||||
`messages` (subject, body); `message_recipients` (recipient_label, destination); `documents`
|
||||
(entity_reference, name, notes).
|
||||
- Numeric columns (`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`
|
||||
(max_score); `assessment_results` (score).
|
||||
|
||||
Text columns match with `Op.iLike '%searchQuery%'`; numeric columns are cast to `varchar` and
|
||||
matched the same way; conditions are combined with `Op.or`. Each returned record is the plain row
|
||||
limited to the searched columns plus `id`, with two added fields: `matchAttribute` (the list of
|
||||
columns whose value contained the lowercased query) and `tableName`. Results from all permitted
|
||||
tables are concatenated into one flat array.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- The service iterates tables in `TABLE_COLUMNS` order and skips any table the user lacks
|
||||
`READ_<TABLE>` permission for.
|
||||
- Records are fetched with `attributes` restricted to the searched text columns, `id`, and the
|
||||
numeric columns; `matchAttribute` is recomputed in JS against the lowercased query.
|
||||
- `SearchService.search` also independently raises `ValidationError('iam.errors.searchQueryRequired')`
|
||||
if `searchQuery` is falsy, though the controller already rejects empty queries with `400`.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `search` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- `file.md` (the other backend infrastructure slice).
|
||||
- Architecture contract: `backend/docs/backend-architecture.md`.
|
||||
172
backend/docs/shared-crud-factories.md
Normal file
172
backend/docs/shared-crud-factories.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Shared CRUD Factories Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
These are the shared building blocks that every generic-CRUD entity slice is assembled
|
||||
from. Instead of copy-pasting a service, controller, router, and repository per entity,
|
||||
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
|
||||
factories.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/services/shared/crud-service.ts` — `createCrudService` (BLL factory) and the
|
||||
`CrudDbApi` repository-shape interface.
|
||||
- `src/api/controllers/shared/crud-controller.ts` — `createCrudController` (API-layer
|
||||
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`).
|
||||
- `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.
|
||||
- `src/db/with-transaction.ts` — `withTransaction` managed-transaction wrapper.
|
||||
- `src/shared/object.ts` — `isRecord` type guard.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### `createCrudService(dbApi, { notFoundCode })` (`src/services/shared/crud-service.ts`)
|
||||
|
||||
Builds the standard BLL service from a repository matching the `CrudDbApi<CreateData,
|
||||
UpdateData, ListFilter, BulkRow, Entity>` interface (`create`, `bulkImport`, `update`,
|
||||
`deleteByIds`, `remove`, `findBy`, `findAll`, `findAllAutocomplete`). The generics are
|
||||
inferred from the passed repository, so the produced service stays fully typed. Returns
|
||||
an object with:
|
||||
|
||||
- `create(data, currentUser?)` — runs `dbApi.create` inside `withTransaction`.
|
||||
- `bulkImport(fileBuffer, currentUser?)` — `parseCsvRows` then `dbApi.bulkImport` with
|
||||
`{ ignoreDuplicates: true, validate: true }` inside `withTransaction`.
|
||||
- `update(data, id, currentUser?)` — `dbApi.update` inside `withTransaction`; throws
|
||||
`ValidationError(notFoundCode)` when the repository returns null.
|
||||
- `remove(id, currentUser?)` / `deleteByIds(ids, currentUser?)` — inside `withTransaction`.
|
||||
- `list(filter, globalAccess, currentUser?)` — `dbApi.findAll(filter, globalAccess, ...)`.
|
||||
- `count(filter, globalAccess, currentUser?)` — `dbApi.findAll` with `countOnly: true`.
|
||||
- `autocomplete(query, limit, offset, globalAccess, organizationId?)` —
|
||||
`dbApi.findAllAutocomplete`.
|
||||
- `findById(id)` — `dbApi.findBy({ id })`.
|
||||
|
||||
### `createCrudController(service, { csvFields })` (`src/api/controllers/shared/crud-controller.ts`)
|
||||
|
||||
Builds the 9 HTTP handlers from a service matching the `CrudControllerService` interface.
|
||||
`csvFields: string[]` selects the columns the CSV list export emits. Returns the handler
|
||||
object typed as `CrudController`. Each handler maps the request to the service and sends
|
||||
the result (see endpoint surface below). `globalAccess` is read from
|
||||
`req.currentUser?.app_role?.globalAccess`.
|
||||
|
||||
### `createCrudRouter(controller, { permission })` (`src/api/http/crud-router.ts`)
|
||||
|
||||
Creates an `express.Router`, applies `permissions.checkCrudPermissions(permission)` to
|
||||
the whole router, and wires the 9 routes (each wrapped with `wrapAsync`).
|
||||
|
||||
### Endpoint surface (the standard 9)
|
||||
|
||||
All nine routes are mounted on the entity base path, guarded by
|
||||
`checkCrudPermissions(permission)`, and respond `200`:
|
||||
|
||||
- `POST /` — creates; body `req.body.data`; sends `true`.
|
||||
- `POST /bulk-import` — runs `processFile`, requires `req.file` (else
|
||||
`ValidationError('importer.errors.invalidFileEmpty')`), imports `req.file.buffer`;
|
||||
sends `true`.
|
||||
- `PUT /:id` — updates; reads both `req.body.data` and `req.body.id` (the id comes from
|
||||
the **body**, not the path param); sends `true`.
|
||||
- `DELETE /:id` — removes by `req.params.id`; sends `true`.
|
||||
- `POST /deleteByIds` — deletes `req.body.data` (an id array); sends `true`.
|
||||
- `GET /` — lists with `req.query`; when `req.query.filetype === 'csv'` it streams a CSV
|
||||
of `csvFields` (via `toCsv` + `res.attachment`), otherwise sends `{ rows, count }`.
|
||||
- `GET /count` — sends the count payload (`findAll` with `countOnly`).
|
||||
- `GET /autocomplete` — reads `query`/`limit`/`offset` from the query string and the
|
||||
caller's `organizationId`; sends the autocomplete payload.
|
||||
- `GET /:id` — sends `findById(req.params.id)`.
|
||||
|
||||
Note on route order: `GET /count` and `GET /autocomplete` are registered before
|
||||
`GET /:id`, so those literal paths are matched ahead of the id parameter.
|
||||
|
||||
### Permission derivation (`src/middlewares/check-permissions.ts`)
|
||||
|
||||
`checkCrudPermissions(name)` derives the permission as
|
||||
`${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`.
|
||||
|
||||
### 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.
|
||||
- `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.
|
||||
|
||||
### Access helpers (`src/services/shared/access.ts`)
|
||||
|
||||
- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleName(currentUser?)`
|
||||
— resolve scope/role from the current user.
|
||||
- `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`.
|
||||
- `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id
|
||||
or throw `ForbiddenError`.
|
||||
- `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user
|
||||
has both an id and an organization.
|
||||
- `hasRoleAccess(currentUser, roleNames)` — `true` for `globalAccess` users or those
|
||||
holding one of `roleNames`.
|
||||
- `campusScope(currentUser, tenantWideRoleNames)` — returns `{}` for tenant-wide/global
|
||||
users, else `{ campusId }` restricting to the user's campus.
|
||||
|
||||
### Validation helpers (`src/services/shared/validate.ts`)
|
||||
|
||||
- `clampLimit(value, defaultLimit, maxLimit)` — parses a positive-integer limit,
|
||||
defaulting and capping it; throws `ValidationError` on invalid input.
|
||||
- `nullableString(value)` — trims a string; returns `null` for non-strings/blanks.
|
||||
- `requiredIsoDate(value)` — requires `YYYY-MM-DD` (`ISO_DATE_PATTERN`); throws otherwise.
|
||||
- `optionalIsoDate(value)` — like `requiredIsoDate`, but `undefined` yields `null`.
|
||||
|
||||
### Other helpers
|
||||
|
||||
- `parseCsvRows<Row>(fileBuffer)` (`src/services/shared/csv-import.ts`) — parses an
|
||||
uploaded CSV buffer into typed rows via a `PassThrough` stream piped through
|
||||
`csv-parser`.
|
||||
- `withTransaction<T>(fn)` (`src/db/with-transaction.ts`) — runs `fn(transaction)` inside
|
||||
a managed Sequelize transaction (`db.sequelize.transaction()`): commits on success,
|
||||
rolls back and rethrows on failure.
|
||||
- `isRecord(value)` (`src/shared/object.ts`) — type guard for a non-null, non-array plain
|
||||
object.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- Tenant scoping lives in each entity repository's `findAll`/`create`/`update`; the
|
||||
factories pass `globalAccess` (from `app_role.globalAccess`) and `currentUser` through
|
||||
unchanged.
|
||||
- All write operations (`create`, `bulkImport`, `update`, `remove`, `deleteByIds`) run in
|
||||
a single managed transaction via `withTransaction`.
|
||||
- `bulkImport` always passes `ignoreDuplicates: true` and `validate: true` to the
|
||||
repository.
|
||||
|
||||
## 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
|
||||
`createCrudRouter(controller, { permission })`, each controller calls
|
||||
`createCrudController(service, { csvFields })`, and each service calls
|
||||
`createCrudService(DbApi, { notFoundCode })`.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- `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,
|
||||
filters, and associations.
|
||||
@ -2,55 +2,106 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
The staff attendance API provides staff-level attendance records and summary counts for the attendance snapshot and director dashboard.
|
||||
`staff_attendance_records` stores staff-level attendance entries per organization. This slice is a
|
||||
read-only reporting surface: it exposes a filtered record list and an aggregated summary used by the
|
||||
attendance snapshot and the director dashboard. It does not write, import, or generate records.
|
||||
|
||||
This workflow is separate from:
|
||||
This is distinct from the student-level attendance models (`attendance_sessions`,
|
||||
`attendance_records`) and from campus daily aggregates (`campus_attendance_summaries`).
|
||||
|
||||
- student-level generated attendance models: `attendance_sessions`, `attendance_records`
|
||||
- campus daily aggregate summaries: `campus_attendance_summaries`
|
||||
## Slice Files (by layer)
|
||||
|
||||
## Data Model
|
||||
|
||||
The module uses:
|
||||
|
||||
- `staff_attendance_records`
|
||||
|
||||
Records include:
|
||||
|
||||
- `attendance_date`
|
||||
- `status`: `present`, `late`, or `absent`
|
||||
- `note`
|
||||
- `user_name`
|
||||
- `user_role`
|
||||
- `organizationId`
|
||||
- `campusId`
|
||||
- `userId`
|
||||
- audit fields and soft delete timestamps
|
||||
- Route: `src/routes/staff_attendance.ts` (thin wiring; `GET /records`, `GET /summary`).
|
||||
- Controller: `src/api/controllers/staff_attendance.controller.ts` (custom — not the CRUD factory).
|
||||
- Service (BLL): `src/services/staff_attendance.ts`.
|
||||
- Repository (DAL): queries run through `db.staff_attendance_records` and `db.staff` inside the
|
||||
service (no separate `db/api/staff_attendance.ts`).
|
||||
- Model: `src/db/models/staff_attendance_records.ts`.
|
||||
- Shared used: `services/shared/access.ts` (`assertAuthenticatedTenantUser`, `requireOrganizationId`,
|
||||
`requireUserId`, `hasRoleAccess`, `campusScope`), `services/shared/validate.ts` (`clampLimit`,
|
||||
`optionalIsoDate`), `shared/constants/staff-attendance.ts`
|
||||
(`STAFF_ATTENDANCE_STATUSES`, `STAFF_ATTENDANCE_REPORT_ROLE_NAMES`,
|
||||
`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`, `STAFF_ATTENDANCE_DEFAULT_LIMIT`,
|
||||
`STAFF_ATTENDANCE_MAX_LIMIT`), `shared/constants/staff.ts` (`STAFF_STATUSES`).
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
The slice is mounted at `/api/staff_attendance`; all routes require JWT authentication (the mount in
|
||||
`src/index.ts` applies the `jwt` passport guard).
|
||||
|
||||
- `GET /api/staff_attendance/records`
|
||||
- `GET /api/staff_attendance/records?startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>&limit=<number>`
|
||||
- `GET /api/staff_attendance/summary`
|
||||
- `GET /api/staff_attendance/summary?startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>&limit=<number>`
|
||||
- `GET /api/staff_attendance/records` -> `200` `{ rows, count }`. Query parameters (all optional):
|
||||
`startDate` and `endDate` (ISO `YYYY-MM-DD`, validated via `optionalIsoDate`), and `limit` (positive
|
||||
integer, defaulting to `STAFF_ATTENDANCE_DEFAULT_LIMIT` = 90 and capped at
|
||||
`STAFF_ATTENDANCE_MAX_LIMIT` = 366 via `clampLimit`). Each row is the record DTO below. Rows are
|
||||
ordered by `attendance_date` descending, then `user_name` ascending.
|
||||
- `GET /api/staff_attendance/summary` -> `200`
|
||||
`{ staffCount, recordsCount, present, late, absent }`. Accepts the same `startDate`, `endDate`, and
|
||||
`limit` query parameters; `limit` is read from the filter type but the summary counts are computed
|
||||
with SQL `COUNT` aggregates, not by limiting rows.
|
||||
|
||||
Invalid `startDate`/`endDate` (non-ISO) or a non-positive / non-integer `limit` raises
|
||||
`ValidationError`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Regular staff can read only their own staff attendance records.
|
||||
- Report roles can read campus-scoped staff attendance records.
|
||||
- Tenant-wide leadership roles can read organization-wide records.
|
||||
- Summary includes active staff count from `staff` plus attendance status counts from `staff_attendance_records`.
|
||||
Enforced by `visibilityScope` in the service:
|
||||
|
||||
## Remaining Related Work
|
||||
- A user who does NOT hold a report role (`STAFF_ATTENDANCE_REPORT_ROLE_NAMES`) sees only their own
|
||||
records, scoped by `userId` (`requireUserId`).
|
||||
- A user who holds a report role sees campus-scoped records via `campusScope`: tenant-wide roles
|
||||
(`STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES`) or users with `globalAccess` see all organization
|
||||
records; other report-role users are restricted to their own campus (`campusId` from their staff
|
||||
profile, else unrestricted if no campus resolves).
|
||||
- `STAFF_ATTENDANCE_REPORT_ROLE_NAMES` = the generated roles Super Administrator, Administrator,
|
||||
Platform Owner, Tenant Director, Campus Manager.
|
||||
- `STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES` = Super Administrator, Administrator, Platform Owner.
|
||||
- `globalAccess` on the user's app role grants access in any role check (`hasRoleAccess`).
|
||||
|
||||
The module currently exposes read/report endpoints only. Add write or import endpoints when the staff attendance source system is defined.
|
||||
Both endpoints call `assertAuthenticatedTenantUser` first; a request without an authenticated user or
|
||||
resolvable organization raises `ForbiddenError`.
|
||||
|
||||
## Files
|
||||
## Tenant Scope
|
||||
|
||||
- `backend/src/constants/staff-attendance.js`
|
||||
- `backend/src/db/models/staff_attendance_records.js`
|
||||
- `backend/src/db/migrations/20260608008000-create-staff-attendance-records.js`
|
||||
- `backend/src/services/staff_attendance.js`
|
||||
- `backend/src/routes/staff_attendance.js`
|
||||
- Every query is bound to the current user's organization (`requireOrganizationId`).
|
||||
- Within the organization, visibility is narrowed to the user, their campus, or the whole tenant per
|
||||
the access rules above. The summary's `staffCount` query applies the same `campusScope` over the
|
||||
`staff` table (active staff only).
|
||||
|
||||
## Data Contract
|
||||
|
||||
Record DTO returned by `GET /records` (`toRecordDto`): `id`, `date` (from `attendance_date`),
|
||||
`status`, `note`, `user_name`, `user_role`, `organizationId`, `campusId`, `userId`, `createdAt`,
|
||||
`updatedAt`.
|
||||
|
||||
Summary DTO returned by `GET /summary`: `staffCount` (active staff in scope, from the `staff` table
|
||||
filtered by `STAFF_STATUSES.ACTIVE`), `recordsCount` (= `present + late + absent`), `present`, `late`,
|
||||
`absent`.
|
||||
|
||||
Model `staff_attendance_records` fields: `id` (UUID PK), `attendance_date` (DATEONLY, not null),
|
||||
`status` (ENUM of `STAFF_ATTENDANCE_STATUSES` — `present`, `late`, `absent`; not null), `note`
|
||||
(TEXT, nullable), `user_name` (TEXT, not null), `user_role` (TEXT, nullable), `importHash`
|
||||
(unique, nullable), `organizationId` (UUID, not null), `campusId` (UUID, nullable), `userId`
|
||||
(UUID, not null), `createdById` (UUID, not null), `updatedById` (UUID, nullable), plus
|
||||
`createdAt`/`updatedAt`/`deletedAt`. The model is `paranoid` (soft delete) with `freezeTableName`.
|
||||
Associations (`belongsTo`): `organization`, `campus`, `user`, `createdBy`, `updatedBy`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- The summary aggregates each status with separate SQL `COUNT` queries (run concurrently with the
|
||||
active-staff count via `Promise.all`) rather than fetching and counting rows in JS, so totals are
|
||||
not truncated by `limit`.
|
||||
- `dateFilter` builds an `attendance_date` range using `Op.gte` / `Op.lte` only for the provided
|
||||
bound(s); with neither bound it adds no date condition.
|
||||
- The list `limit` defaults and caps come from the staff-attendance constants, not the shared
|
||||
pagination helper.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `staff_attendance` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/staff-attendance-integration.md`.
|
||||
- Related slices: `campus-attendance` (campus daily aggregates), the student-level
|
||||
`attendance_sessions` / `attendance_records` slices, and `staff` (active staff count, campus
|
||||
resolution).
|
||||
|
||||
96
backend/docs/staff.md
Normal file
96
backend/docs/staff.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Staff Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`staff` is the per-organization employee profile roster, linking an optional `user` account and
|
||||
`campus` to each staff member. It is a generic-CRUD slice assembled from the shared factories;
|
||||
the backend is the source of truth for staff records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/staff.ts` — `createCrudRouter(controller, { permission: 'staff' })`.
|
||||
- Controller: `src/api/controllers/staff.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/staff.ts` — `createCrudService(DbApi, { notFoundCode: 'staffNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/staff.ts` (`StaffDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/staff.ts`.
|
||||
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
|
||||
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
|
||||
(`db/api/shared/repository.ts`), `shared/constants/pagination.ts` (`resolvePagination`),
|
||||
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`), `db/utils.ts` (`Utils`),
|
||||
`db/api/file.ts` (`FileDBApi.replaceRelationFiles` for the `photo` relation).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/staff`, JWT + `${METHOD}_STAFF` permission,
|
||||
all `200`) — see `backend-architecture.md` for the shared contract:
|
||||
|
||||
- `POST /` — body `{ data }`, returns `true`.
|
||||
- `POST /bulk-import` — multipart CSV file, returns `true`.
|
||||
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path
|
||||
param), returns `true`.
|
||||
- `DELETE /:id` — returns `true`.
|
||||
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
|
||||
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
|
||||
- `GET /count` — returns `{ rows: [], count }`.
|
||||
- `GET /autocomplete` — `?query&limit&offset`, returns `[{ id, label }]` where `label` is
|
||||
`employee_number`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `employee_number`, `job_title`, `hire_date`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('staff')`, deriving
|
||||
`READ_STAFF` / `CREATE_STAFF` / `UPDATE_STAFF` / `DELETE_STAFF` per HTTP method.
|
||||
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- `findAll` scopes `where.organizationId` to `currentUser.organizationId`; a `globalAccess`
|
||||
role clears the org filter (sees all tenants).
|
||||
- `create` assigns the organization from `currentUser.organizationId`; `update` only reassigns
|
||||
organization for `globalAccess` users (otherwise it stays the caller's org), and only when
|
||||
`data.organization` is provided.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`paranoid`, soft-delete via `deletedAt`):
|
||||
|
||||
- `id` (UUID PK), `employee_number`, `job_title` (TEXT, nullable).
|
||||
- `staff_type` — ENUM `teacher` | `admin` | `support`.
|
||||
- `status` — ENUM `active` | `on_leave` | `inactive`.
|
||||
- `hire_date` — DATE.
|
||||
- `importHash` (unique), `campusId`, `organizationId`, `userId`, `createdById`, `updatedById`,
|
||||
timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, campus, user (a `users` record), createdBy/updatedBy
|
||||
(users); `hasMany` `classes_homeroom_teacher` (classes via `homeroom_teacherId`),
|
||||
`class_subjects_teacher` (class_subjects via `teacherId`), `attendance_sessions_taken_by`
|
||||
(attendance_sessions via `taken_byId`), `payments_received_by` (payments via `received_byId`);
|
||||
`hasMany` file as `photo` (scoped relation). `findBy`/`GET /:id` eager-load all of these in a
|
||||
single `Promise.all`.
|
||||
|
||||
List filters (`StaffFilter`): `id`, `employee_number`, `job_title`, `hire_dateRange`,
|
||||
`staff_type`, `status`, `campus` (id or name, `|`-separated), `user` (id or `firstName`,
|
||||
`|`-separated), `organization` (id list, `|`-separated), `createdAtRange`, plus `field`/`sort`
|
||||
ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`bulkImport`/`update` manage the `photo` file relation via
|
||||
`FileDBApi.replaceRelationFiles`.
|
||||
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order.
|
||||
- List pagination uses the shared `resolvePagination` defaults (page size 10, capped at 100).
|
||||
- Note: `StaffFilter` accepts an `active` flag and `findAll` filters on an `active` column, but
|
||||
the model has no `active` column; this filter is currently inert (kept for source accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `organizations`, `campuses`,
|
||||
`classes`, `class_subjects`, `attendance_sessions`, `payments`, `file.md`, `permissions.md`.
|
||||
94
backend/docs/students.md
Normal file
94
backend/docs/students.md
Normal file
@ -0,0 +1,94 @@
|
||||
# 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`.
|
||||
83
backend/docs/subjects.md
Normal file
83
backend/docs/subjects.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Subjects Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`subjects` is the per-organization catalog of teaching subjects (name, code, description). It is
|
||||
a generic-CRUD slice assembled from the shared factories; the backend is the source of truth for
|
||||
subject records.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/subjects.ts` — `createCrudRouter(controller, { permission: 'subjects' })`.
|
||||
- Controller: `src/api/controllers/subjects.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/subjects.ts` — `createCrudService(DbApi, { notFoundCode: 'subjectsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/subjects.ts` (`SubjectsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/subjects.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/subjects`, JWT + `${METHOD}_SUBJECTS`
|
||||
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**), 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`, `code`, `description`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('subjects')`, deriving
|
||||
`READ_SUBJECTS` / `CREATE_SUBJECTS` / `UPDATE_SUBJECTS` / `DELETE_SUBJECTS` 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`, `code`, `description` — TEXT, nullable.
|
||||
- `importHash` (unique), `organizationId`, `createdById`, `updatedById`, timestamps.
|
||||
|
||||
Associations: `belongsTo` organization, createdBy/updatedBy (users); `hasMany`
|
||||
`class_subjects_subject`. `findBy`/`GET /:id` eager-load class_subjects_subject and organization
|
||||
in a single `Promise.all`.
|
||||
|
||||
List filters (`SubjectsFilter`): `id`, `name` (ilike), `code` (ilike), `description` (ilike),
|
||||
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`update` wire the organization relation via the `setOrganization` association mixin.
|
||||
- `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: `SubjectsFilter` 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: `class_subjects`, `classes`,
|
||||
`permissions.md`.
|
||||
94
backend/docs/timetable_periods.md
Normal file
94
backend/docs/timetable_periods.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Timetable Periods Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`timetable_periods` is a single scheduled slot within a timetable — a weekday, start/end time,
|
||||
room, and the class subject taught. It is a generic-CRUD slice assembled from the shared
|
||||
factories and belongs to one `timetables` row.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/timetable_periods.ts` — `createCrudRouter(controller, { permission: 'timetable_periods' })`.
|
||||
- Controller: `src/api/controllers/timetable_periods.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/timetable_periods.ts` — `createCrudService(DbApi, { notFoundCode: 'timetable_periodsNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/timetable_periods.ts` (`Timetable_periodsDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/timetable_periods.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/utils.ts` (`Utils.uuid`/`Utils.ilike`), `shared/constants/database.ts`
|
||||
(`BULK_IMPORT_TIMESTAMP_STEP_MS`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/timetable_periods`, JWT +
|
||||
`${METHOD}_TIMETABLE_PERIODS` permission, all `200`) — see `backend-architecture.md` for the
|
||||
shared 9-endpoint 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**), 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
|
||||
`room`.
|
||||
- `GET /:id` — returns the record with eager associations (see Data Contract).
|
||||
|
||||
`csvFields`: `id`, `room`, `starts_at`, `ends_at`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('timetable_periods')`,
|
||||
deriving `READ_TIMETABLE_PERIODS` / `CREATE_TIMETABLE_PERIODS` / `UPDATE_TIMETABLE_PERIODS` /
|
||||
`DELETE_TIMETABLE_PERIODS` 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).
|
||||
- `day_of_week` — ENUM `monday` | `tuesday` | `wednesday` | `thursday` | `friday` | `saturday`
|
||||
| `sunday`.
|
||||
- `starts_at`, `ends_at` — DATE.
|
||||
- `room` — TEXT.
|
||||
- `importHash` (STRING(255), unique), `class_subjectId`, `organizationId`, `timetableId`,
|
||||
`createdById`, `updatedById`, timestamps (all UUID FKs nullable).
|
||||
|
||||
Associations: `belongsTo` organization, timetable (`timetables`),
|
||||
class_subject (`class_subjects`), createdBy/updatedBy (users). `findBy`/`GET /:id` eager-load
|
||||
organization, timetable, and class_subject in a single `Promise.all`.
|
||||
|
||||
List filters (`TimetablePeriodsFilter`): `id`, `room` (iLike), `starts_atRange`, `ends_atRange`,
|
||||
`day_of_week`, `timetable` (id or name, `|`-separated), `class_subject` (id or status),
|
||||
`organization`, `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`bulkImport`/`update` set associations via the Sequelize `set*` mixins (no file
|
||||
relations on this entity).
|
||||
- `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: `TimetablePeriodsFilter` accepts an `active` flag the model has no column for; it is
|
||||
applied to `where.active` but, with no such column, is currently inert (kept for source
|
||||
accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `timetables`,
|
||||
`class_subjects`, `permissions.md`.
|
||||
95
backend/docs/timetables.md
Normal file
95
backend/docs/timetables.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Timetables Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`timetables` is a named, dated schedule (a campus/academic-year timetable with a draft/active/
|
||||
archived status) that owns a set of `timetable_periods`. It is a generic-CRUD slice assembled
|
||||
from the shared factories.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/timetables.ts` — `createCrudRouter(controller, { permission: 'timetables' })`.
|
||||
- Controller: `src/api/controllers/timetables.controller.ts` — `createCrudController(service, { csvFields })`.
|
||||
- Service (BLL): `src/services/timetables.ts` — `createCrudService(DbApi, { notFoundCode: 'timetablesNotFound' })`.
|
||||
- Repository (DAL): `src/db/api/timetables.ts` (`TimetablesDBApi`) — entity-specific
|
||||
`create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete`
|
||||
delegate to `db/api/shared/repository.ts`.
|
||||
- Model: `src/db/models/timetables.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/utils.ts` (`Utils.uuid`/`Utils.ilike`), `shared/constants/database.ts`
|
||||
(`BULK_IMPORT_TIMESTAMP_STEP_MS`).
|
||||
|
||||
## API
|
||||
|
||||
The standard generic-CRUD surface (all under `/api/timetables`, JWT +
|
||||
`${METHOD}_TIMETABLES` permission, all `200`) — see `backend-architecture.md` for the shared
|
||||
9-endpoint 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**), 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`, `effective_from`, `effective_to`.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- JWT required; the whole router is guarded by `checkCrudPermissions('timetables')`, deriving
|
||||
`READ_TIMETABLES` / `CREATE_TIMETABLES` / `UPDATE_TIMETABLES` / `DELETE_TIMETABLES` 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` — TEXT.
|
||||
- `effective_from`, `effective_to` — DATE.
|
||||
- `status` — ENUM `draft` | `active` | `archived`.
|
||||
- `importHash` (STRING(255), unique), `academic_yearId`, `campusId`, `organizationId`,
|
||||
`createdById`, `updatedById`, timestamps (all UUID FKs nullable).
|
||||
|
||||
Associations: `belongsTo` organization, campus, academic_year (`academic_years`),
|
||||
createdBy/updatedBy (users); `hasMany` `timetable_periods` as `timetable_periods_timetable`.
|
||||
`findBy`/`GET /:id` eager-load `timetable_periods_timetable`, organization, campus, and
|
||||
academic_year in a single `Promise.all`.
|
||||
|
||||
List filters (`TimetablesFilter`): `id`, `name` (iLike), `effective_fromRange`,
|
||||
`effective_toRange`, `status`, `campus` (id or name, `|`-separated), `academic_year` (id or
|
||||
name), `organization`, `createdAtRange`, plus a calendar overlap pair `calendarStart` +
|
||||
`calendarEnd` (matches rows whose `effective_from` **or** `effective_to` falls between the two),
|
||||
and `field`/`sort` ordering and `limit`/`page` pagination.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- `create`/`bulkImport`/`update` set associations via the Sequelize `set*` mixins (no file
|
||||
relations on this entity).
|
||||
- `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: `TimetablesFilter` accepts an `active` flag the model has no column for; it is applied
|
||||
to `where.active` but, with no such column, is currently inert (kept for source accuracy).
|
||||
|
||||
## Tests
|
||||
|
||||
None yet.
|
||||
|
||||
## Related
|
||||
|
||||
- Generic-CRUD contract: `backend-architecture.md`; related slices: `timetable_periods`,
|
||||
`academic_years`, `campuses`, `permissions.md`.
|
||||
@ -1,427 +0,0 @@
|
||||
# План миграции бэкэнда на TypeScript + ESM
|
||||
|
||||
Статус: черновик плана (реализация ещё не начата). Дата: 2026-06-09.
|
||||
|
||||
Этот документ описывает полную, поэтапную миграцию `backend/` с CommonJS/JavaScript на
|
||||
TypeScript со строгим режимом и нативные ESM-модули (`import`/`export`). План составлен
|
||||
так, чтобы приложение оставалось рабочим на каждом шаге и соответствовало правилам
|
||||
`CLAUDE.md` (без `any`, без отключения линтера/TS, без приведения типов, импорты через
|
||||
алиас `@`, документация и тесты на каждый модуль, минимальные изменения, без
|
||||
оверинжиниринга).
|
||||
|
||||
---
|
||||
|
||||
## 0. Принятые решения (2026-06-09)
|
||||
|
||||
Зафиксированы по итогам обсуждения и проверочных spike:
|
||||
|
||||
1. **Дата/время:** `moment` → **`dayjs`** (минимальный diff, близкий API).
|
||||
2. **sequelize-cli в ESM (spike проведён):** подтверждено — CLI загружает `.cjs`-конфиг и `.sequelizerc` в пакете с `"type": "module"`. Но т.к. выбраны TS-миграции (см. п.4), sequelize-cli из миграционного флоу убирается.
|
||||
3. **Инструменты:** dev — раннер **`tsx`** (исполняет `.ts`+ESM напрямую, это инструмент, не React-разметка), прод — сборка **`tsc` + `tsc-alias`**. Бандлер не вводим.
|
||||
4. **Миграции — на TypeScript.** Используем **Umzug 3 + `tsx`** (spike подтвердил выполнение `.ts`-миграций end-to-end). Хранилище — `SequelizeStorage` поверх существующей таблицы `SequelizeMeta`, поэтому история уже применённых миграций сохраняется. Лучшие практики — см. раздел 11.
|
||||
5. **Версия Node:** **24 (Active LTS)**. Удовлетворяет самым строгим ограничениям зависимостей (`vitest`, `eslint` требуют `>=24`). Node 26 — ещё «Current», не LTS, в прод не берём.
|
||||
6. **Тесты:** минимальный смоук-набор на этапе миграции (+ unit на чистые модули).
|
||||
7. **OAuth-стратегии passport:** обновление — **отдельной задачей** после миграции; задача внесена в `docs/full-integration-refactor-plan.md`.
|
||||
|
||||
ORM остаётся **Sequelize 6.37** (решение зафиксировано отдельно: Prisma — слишком большой рефакторинг, v7 — alpha).
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущее состояние (факты, на которых строится план)
|
||||
|
||||
Замеры по `backend/src` (без `node_modules`):
|
||||
|
||||
- Всего файлов `.js`: **206**, суммарно ~**55 651** строк.
|
||||
- Распределение по слоям:
|
||||
- `db/` — 90 файлов (модели 39, db/api 28, миграции 15, сидеры 5), ~33 400 строк — самый объёмный слой.
|
||||
- `routes/` — 45 файлов, ~12 700 строк.
|
||||
- `services/` — 48 файлов, ~7 600 строк.
|
||||
- `constants/` — 13 файлов, `middlewares/` — 3, `auth/` — 2, `config/` — 1 + `config.js`, `ai/` — 1.
|
||||
- Модульная система: **100% CommonJS**. `require(` используется в 173 файлах, `module.exports` — в 201; ESM-импортов (`import …`) — **0**.
|
||||
- Экспорт-паттерны: `module.exports = class` — 74 файла (services, db/api, middlewares, helpers); `module.exports = function(sequelize, DataTypes)` — 38 (фабрики моделей Sequelize).
|
||||
- ORM: **Sequelize 6.37**, миграции/сидеры через **sequelize-cli 6.6.5** (CommonJS-инструмент, читает `.sequelizerc` → `src/db/db.config.js`).
|
||||
- Express **5.2**, аутентификация на **passport** (JWT + Google + Microsoft), документация через **swagger-jsdoc** (парсит `./src/routes/*.js`).
|
||||
- Dev-запуск: `watcher.js` (chokidar + nodemon), он же триггерит `db:migrate` / `db:seed` при появлении новых файлов.
|
||||
- ESLint flat-config с `sourceType: 'commonjs'` и правилом `import-x/no-unresolved`.
|
||||
- Тестов в бэкэнде сейчас **нет** (0 файлов `*.test.js` / `*.spec.js`); проверка — только `node -c` (синтаксис) согласно `docs/full-integration-refactor-plan.md`.
|
||||
- Алиас `@` в бэкэнде пока **не используется** (хотя `CLAUDE.md` его требует); все импорты относительные.
|
||||
- Окружения: `engines.node >= 18`, Dockerfile на `node:20.15.1-alpine`, локально доступен Node 22.
|
||||
|
||||
Важный ориентир: **фронтенд уже на TypeScript + ESM + Vite** (strict, `paths: { "@/*": ["./src/*"] }`,
|
||||
`vitest`, `playwright`). Бэкэнд логично привести к тем же конвенциям (strict TS, алиас `@`, vitest).
|
||||
|
||||
### ESM-блокеры, выявленные в коде
|
||||
|
||||
1. **Динамическая загрузка моделей** в `db/models/index.js`: `fs.readdirSync(__dirname)` + `require(path.join(...))` в цикле. В нативном ESM `require` недоступен; нужен либо явный список `import`, либо динамический `import()`.
|
||||
2. **`__dirname` / `__filename`** в 7 файлах: `config/load-env.js`, `index.js`, `ai/LocalAIApi.js`, `db/models/index.js`, `services/email/list/{passwordReset,addressVerification,invitation}.js`. В ESM их нет — замена на `fileURLToPath(import.meta.url)`.
|
||||
3. **sequelize-cli** загружает `.sequelizerc`, `db.config.js`, миграции и сидеры как CommonJS через `require`. При `"type": "module"` файлы `.js` станут ESM и CLI сломается.
|
||||
4. **swagger-jsdoc** указывает на `./src/routes/*.js` — после сборки путь к исходникам/выходу изменится.
|
||||
5. **`watcher.js` + nodemon** заточены под запуск `.js`-файла напрямую.
|
||||
6. В ESM (NodeNext) **относительные импорты требуют явного расширения** (`./foo.js`), а алиасы tsconfig **не резолвятся Node в рантайме** без дополнительного инструмента.
|
||||
|
||||
---
|
||||
|
||||
## 2. Целевое состояние
|
||||
|
||||
- Весь рантайм-код бэкэнда — `.ts` со строгим TypeScript (`strict: true`, `noImplicitAny`, `strictNullChecks`), без `any` и без приведения типов.
|
||||
- Нативный ESM: `"type": "module"` в `package.json`, `import`/`export` во всём рантайм-коде.
|
||||
- Импорты через алиас `@` (как на фронтенде), работающие и в dev, и в прод-сборке.
|
||||
- Сборка `tsc` → `dist/`, запуск прод из `dist/`; dev — через `tsx watch` (TS + ESM + алиасы без отдельной сборки).
|
||||
- Миграции и сидеры остаются на sequelize-cli и сохраняют CommonJS (расширение `.cjs`) — как исторические артефакты, переписывать их на TS нецелесообразно.
|
||||
- Введён минимальный каркас типизированных тестов (`vitest`) и `typecheck` как гейт качества.
|
||||
- Обновлены Dockerfile, скрипты `package.json`, ESLint и документация.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ключевые проектные решения (с обоснованием)
|
||||
|
||||
### 3.1. Две фазы вместо «большого взрыва»
|
||||
|
||||
Задача совмещает две независимые трансформации: смену **языка** (JS → TS) и смену
|
||||
**модульной системы** (CJS → ESM). Делать обе сразу на 206 файлах рискованно. Рекомендуется
|
||||
разделить:
|
||||
|
||||
- **Фаза A — типизация (остаёмся на CommonJS).** TypeScript компилируется в `module: "CommonJS"`. Включаем `allowJs: true`, чтобы `.ts` и `.js` сосуществовали, и мигрируем файлы снизу вверх по дереву зависимостей. Приложение всё это время запускается как раньше. Риск минимальный.
|
||||
- **Фаза B — перевод на ESM.** Когда весь код уже на TS, механически переводим модульную систему: `"type": "module"`, `module: "NodeNext"`, `require`→`import`, `module.exports`→`export`, `__dirname`→`import.meta.url`, переписываем динамический загрузчик моделей, чиним sequelize-cli и swagger-пути.
|
||||
|
||||
Такое разделение изолирует «языковые» ошибки от «модульных» и даёт чёткие точки отката.
|
||||
|
||||
> Альтернатива (не рекомендуется): одновременный переход на `tsx` + `"type": "module"` и
|
||||
> правка всех импортов сразу. Быстрее по числу шагов, но даёт большой нестабильный
|
||||
> diff и трудный откат. Противоречит принципу «минимальные изменения».
|
||||
|
||||
### 3.2. Инструмент сборки и запуска
|
||||
|
||||
- **Сборка:** `tsc` (официальный компилятор) в `dist/`. Просто, предсказуемо, без бандлера — соответствует «без оверинжиниринга».
|
||||
- **Алиасы `@` в выходной сборке:** `tsc` не переписывает `@/*` в относительные пути. Добавляем `tsc-alias` как пост-шаг сборки. (Альтернатива — нативные `imports` в `package.json` с префиксом `#`, но `CLAUDE.md` требует именно `@`.)
|
||||
- **Dev-запуск:** `tsx watch src/index.ts` — исполняет TS+ESM напрямую и резолвит `paths` из tsconfig, заменяя `nodemon`. Авто-миграции/сидинг из `watcher.js` выносим в отдельный `predev`-шаг или оставляем лёгкий watcher только для `db/migrations`+`db/seeders` (см. 4.7).
|
||||
|
||||
### 3.3. Миграции — переход на Umzug + TypeScript (решение принято)
|
||||
|
||||
sequelize-cli — CommonJS-инструмент и не поддерживает `.ts`-миграции. Поскольку выбраны
|
||||
TS-миграции (решение 0.4), **отказываемся от sequelize-cli** в пользу **Umzug 3** — это та же
|
||||
библиотека, что лежит под капотом sequelize-cli, поэтому переход совместим по хранилищу
|
||||
истории.
|
||||
|
||||
Подход (детали и пример каркаса — в разделе 11):
|
||||
|
||||
- Собственный лёгкий раннер `db/migrate.ts` на Umzug, запускаемый через `tsx`.
|
||||
- Хранилище истории — `SequelizeStorage` поверх существующей таблицы **`SequelizeMeta`**. Уже применённые миграции остаются записанными, повторно не выполняются.
|
||||
- **Новые** миграции пишутся как `.ts` (ESM, типизированный `QueryInterface`).
|
||||
- **Существующие 15 миграций** не переписываем: оставляем как есть в формате `.cjs` (исторические артефакты), Umzug-glob включает и `.cjs`, и `.ts`, `tsx` грузит оба. Это исключает риск нарушить уже применённую историю БД.
|
||||
- Сидеры — вторым экземпляром Umzug (отдельная meta-таблица) либо оставляем минимальный текущий механизм; решается в разделе 11.
|
||||
|
||||
> Spike подтвердил: Umzug 3.8.3 + `tsx` выполняют `.ts`-миграции end-to-end. Отдельно
|
||||
> подтверждено, что `.cjs`-артефакты корректно грузятся в ESM-пакете, что нужно для
|
||||
> сосуществования старых `.cjs` и новых `.ts` миграций.
|
||||
|
||||
Модели переводим на TS/ESM (их импортирует рантайм). Команды миграций моделей не требуют.
|
||||
|
||||
### 3.4. Загрузчик моделей и типобезопасный объект `db`
|
||||
|
||||
Заменяем `fs.readdirSync + require` на **явные `import` всех 39 моделей** в `db/models/index.ts`
|
||||
и сборку строго типизированного объекта `db`. Это убирает динамику (несовместимую с ESM-статикой),
|
||||
даёт автодополнение и исключает `any`. Минус — вербозный index с 39 импортами, но это
|
||||
разовый предсказуемый код. **Динамический `import()` здесь не используем** — только статические.
|
||||
|
||||
### 3.4.1. Политика импортов: статика по умолчанию
|
||||
|
||||
Решение: **избегаем динамических импортов (`import()`)**, кроме случаев, где это действительно
|
||||
оправдано. На текущем коде таких случаев нет:
|
||||
|
||||
- Единственный реальный динамический `require` — загрузчик моделей (`db/models/index.js:25`) — переводится на статические импорты (3.4).
|
||||
- Найденные `import("…")` — это JSDoc-аннотации типов, а не рантайм; в TS становятся обычными `import type`.
|
||||
- `__dirname`/`__filename` в 7 файлах используются для `fs`-чтения файлов (`.env`, HTML-шаблоны писем), **не для импортов**. Замена на `path.dirname(fileURLToPath(import.meta.url))` сохраняет их статическими — это не динамический импорт.
|
||||
|
||||
Если в будущем потребуется ленивая загрузка (тяжёлый опциональный модуль, разрыв цикла
|
||||
зависимостей), динамический `import()` допускается **точечно и с обоснованием в коде**, а не как
|
||||
паттерн. Циклы зависимостей предпочтительно разрывать рефакторингом, а не `import()`.
|
||||
|
||||
### 3.5. Стратегия типизации моделей Sequelize
|
||||
|
||||
Модели сейчас — фабрики `sequelize.define('name', {...})`. Чтобы не переписывать 39 моделей в
|
||||
class-based стиль (большой рискованный diff), рекомендуется **остаться на `define()`**, но
|
||||
добавить типобезопасность:
|
||||
|
||||
- На каждую модель — интерфейс атрибутов (`XAttributes`) и creation-атрибутов (`XCreationAttributes`).
|
||||
- Фабрика возвращает `ModelStatic<Model<XAttributes, XCreationAttributes>>`.
|
||||
- `associate` типизируется через общий тип реестра `Db`.
|
||||
|
||||
Это самый объёмный по трудозатратам пункт миграции (39 моделей + связи), поэтому он вынесен в
|
||||
отдельную подфазу и может идти параллельно остальному после готовности каркаса.
|
||||
|
||||
### 3.6. Типизация Express и слоёв
|
||||
|
||||
- `@types/express`, `Request`/`Response`/`NextFunction`, расширение `Request` полем `currentUser` через declaration merging (`src/types/express.d.ts`).
|
||||
- `helpers.wrapAsync`, `commonErrorHandler`, middlewares — строго типизированные сигнатуры.
|
||||
- db/api и services — публичные методы получают типы входных DTO и возвращаемых моделей.
|
||||
|
||||
---
|
||||
|
||||
## 4. Поэтапный план работ
|
||||
|
||||
### Фаза 0 — Подготовка инструментов (без изменения рантайм-поведения)
|
||||
|
||||
1. Установить dev-зависимости: `typescript`, `tsx`, `tsc-alias`, `@types/node`, и типы для библиотек без собственных деклараций: `@types/express`, `@types/cors`, `@types/passport`, `@types/passport-jwt`, `@types/jsonwebtoken`, `@types/nodemailer`, `@types/multer`, `@types/swagger-jsdoc`, `@types/swagger-ui-express`, `@types/bcrypt`, `@types/validator`. (axios, sequelize, pg, `dayjs`, `@json2csv/plainjs`, `umzug` поставляют типы сами; `@types/lodash` не нужен — `lodash` удаляется.) Добавить рантайм-зависимость `umzug` (для TS-миграций, см. раздел 11).
|
||||
2. Создать `backend/tsconfig.json` (Фаза A — CommonJS):
|
||||
- `strict: true`, `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch` (зеркало фронтенда).
|
||||
- `target: ES2022`, `module: CommonJS`, `moduleResolution: Node`, `esModuleInterop: true`.
|
||||
- `allowJs: true`, `checkJs: false` — для сосуществования `.js` и `.ts`.
|
||||
- `outDir: dist`, `rootDir: src`, `baseUrl: src`, `paths: { "@/*": ["*"] }`.
|
||||
3. Обновить ESLint: добавить `typescript-eslint`, парсер для `.ts`, сохранить `import-x/no-unresolved` с TS-резолвером. На Фазе A не ломать существующие `.js`-правила.
|
||||
4. Добавить скрипт `typecheck: tsc --noEmit` и завести его как обязательный гейт.
|
||||
|
||||
Критерий готовности фазы: `npm run typecheck` проходит на смешанной кодовой базе, приложение запускается как раньше.
|
||||
|
||||
### Фаза 0.5 — Аудит и обновление зависимостей (совмещается с Фазой A)
|
||||
|
||||
Полный разбор — в разделе 10. Кратко по порядку:
|
||||
|
||||
1. **Удалить неиспользуемые** драйверы и пакеты: `mysql2`, `tedious`, `sqlite` (диалект везде `postgres`, 0 использований), `lodash` (один импорт `lodash/get`, заменяется на optional chaining).
|
||||
2. **Заменить deprecated** `json2csv@5.0.7` → `@json2csv/plainjs@7` (26 файлов).
|
||||
3. **Заменить устаревшую** `moment` → **`dayjs`** (26 файлов).
|
||||
4. **Консолидировать загрузку файлов**: убрать `formidable` (1 файл), оставить `multer`; убрать `body-parser` → `express.json()` (Express 5).
|
||||
5. **Убрать `sequelize-cli`** (миграции переходят на Umzug + tsx, см. раздел 11). OAuth-стратегии — отдельной задачей (см. `full-integration-refactor-plan.md`); `sequelize-json-schema` пересмотреть при типизации `routes/openai.ts`.
|
||||
6. Минорно поднять актуальные пакеты (`@google-cloud/storage` 7.19→7.21 и т.п.).
|
||||
|
||||
Эти изменения лучше делать вместе с типизацией соответствующих модулей, чтобы новые типы сразу ложились на новые API. Каждое — отдельным PR со смоук-проверкой.
|
||||
|
||||
### Фаза A — Перевод на TypeScript (язык), модульная система = CommonJS
|
||||
|
||||
Мигрируем снизу вверх по зависимостям, по одному связному блоку за раз; после каждого блока — `typecheck` + ручной/смоук-прогон.
|
||||
|
||||
1. `constants/` (13 файлов) — листовые, без зависимостей. Перевести в `.ts`, экспортировать типизированные константы (`as const`).
|
||||
2. `config/load-env.ts`, `config.ts` — типизировать чтение env (хелперы `requiredEnv`/`readBooleanEnv`/`readNumberEnv`/`readListEnv` уже есть, добавить типы и тип `Config`).
|
||||
3. `helpers.ts`, `db/utils.ts` — общие утилиты.
|
||||
4. `db/models/*` — типизация моделей (см. 3.5) + типобезопасный `db/models/index.ts` (см. 3.4). Самый крупный блок; можно дробить по доменам.
|
||||
5. `db/api/*` (28 файлов) — типизировать публичные статические методы, опираясь на типы моделей.
|
||||
6. `services/*` (48 файлов), включая `services/notifications/errors/*`, `services/email/list/*`.
|
||||
7. `auth/*` и `middlewares/*` — типизация passport-стратегий, cookies, проверки прав; declaration merging для `Request.currentUser`.
|
||||
8. `ai/LocalAIApi.ts`.
|
||||
9. `routes/*` (45 файлов) — типизированные роутеры; swagger-JSDoc-комментарии переносятся как есть (swagger-jsdoc парсит и `.ts`).
|
||||
10. `index.ts` — точка входа.
|
||||
11. Существующие миграции/сидеры/`db.config`/`.sequelizerc` — на Фазе A **не трогаем** (остаются `.js`, CommonJS-пакет их корректно исполняет). Перевод на Umzug — в Фазе B.
|
||||
|
||||
Критерий готовности: 0 `.js` в рантайм-коде (кроме исторических миграций/сидеров), `typecheck` зелёный, приложение работает.
|
||||
|
||||
### Фаза B — Перевод на ESM (модульная система)
|
||||
|
||||
1. Переключить tsconfig на ESM: `module: NodeNext`, `moduleResolution: NodeNext`. Отключить `allowJs` (язык уже весь TS).
|
||||
2. Поставить `"type": "module"` в `package.json`.
|
||||
3. Конвертировать синтаксис во всех `.ts`: `require`→`import`, `module.exports`→`export` / `export default`. (Можно ускорить кодмодом `cjstoesm` — но результат вычитывать вручную; правило: не доверять слепо.)
|
||||
4. `__dirname`/`__filename` → `path.dirname(fileURLToPath(import.meta.url))` в 7 файлах.
|
||||
5. Переписать `db/models/index.ts` на статические `import` всех моделей.
|
||||
6. Поправить относительные импорты под NodeNext (явные расширения в выходе обеспечивает `tsc-alias`; алиас `@` — предпочтительный путь).
|
||||
7. **Миграции на Umzug + tsx (раздел 11):** убрать `sequelize-cli` и `.sequelizerc`; перевести `db.config.js`→`db/config.ts`; добавить раннер `db/migrate.ts` (Umzug + `SequelizeStorage` на `SequelizeMeta`); существующие 15 миграций оставить как `.cjs`, новые писать на `.ts`. Проверить `migrate up` на чистой БД и на БД с уже применённой историей.
|
||||
8. **swagger-jsdoc:** сделать путь `apis` env-зависимым — dev: `./src/routes/*.ts`, прод: `./dist/routes/*.js`.
|
||||
9. **dev-запуск:** заменить `nodemon`/`watcher.js` на `tsx watch src/index.ts` (hot-reload + стриминг логов в реальном времени). Авто-применение миграций — `predev`-шаг (`tsx src/db/migrate.ts up`); если нужно сохранить применение «на лету» при добавлении файла, оставить лёгкий chokidar-вотчер на `db/migrations`/`db/seeders`. Паритет с текущим поведением проверяется в Фазе D, п.4.
|
||||
10. `db/reset.js` (использует `execSync('sequelize ...')`) — переписать на вызов Umzug-раннера (`migrate down/up` или `sequelize.sync` для dev), перевести в `.ts`/ESM.
|
||||
|
||||
Критерий готовности: `npm run build` (tsc + tsc-alias) собирает `dist/`, `node dist/index.js` стартует, миграции/сидеры проходят, все маршруты отвечают.
|
||||
|
||||
### Фаза C — Скрипты, сборка, контейнеризация
|
||||
|
||||
1. `package.json` скрипты:
|
||||
- `dev: tsx watch src/index.ts`
|
||||
- `build: tsc && tsc-alias`
|
||||
- `start: node dist/index.js` (прод; миграции — отдельной командой перед стартом)
|
||||
- `db:migrate: tsx src/db/migrate.ts up`, `db:rollback: tsx src/db/migrate.ts down`, `db:seed: tsx src/db/seed.ts` (раздел 11)
|
||||
- `typecheck`, `lint`, `test`.
|
||||
- Указать `engines.node: ">=24"` (Active LTS, решение 0.5).
|
||||
2. Обновить корневой `package.json` (`build:production`, `start:production`) под новый build-флоу бэкэнда.
|
||||
3. **Копирование ассетов в `dist/`.** `tsc` собирает только `.ts`. Не-TS ассеты, которые читаются через `fs` по пути от `import.meta.url`, нужно копировать в `dist/` отдельным build-шагом: HTML-шаблоны писем `src/services/email/htmlTemplates/**` (используются в `services/email/list/*`). Добавить в `build` (например, `cpy`/`copyfiles`/`rsync`) и проверить, что пути от `import.meta.url` резолвятся в `dist/`.
|
||||
4. Dockerfile: базовый образ **`node:24-alpine`** (текущая Active LTS); добавить шаг `npm run build`, в прод-образ копировать `dist/` (включая скопированные шаблоны) + `node_modules` (multi-stage build). Заменить `yarn install` на `npm ci` (консистентно с корневым `build:production`).
|
||||
5. `.dockerignore`/`.gitignore`: добавить `dist/`.
|
||||
|
||||
### Фаза D — Тесты и верификация
|
||||
|
||||
1. Добавить `vitest` (консистентно с фронтендом) и базовый каркас тестов.
|
||||
2. Покрыть unit-тестами критичные чистые модули: `config` (парсинг env), `helpers`, `db/utils`, auth-хелперы, проверки прав.
|
||||
3. Смоук-проверка рантайма: старт сервера, `/api-docs`, healthcheck, 1–2 защищённых маршрута.
|
||||
4. **Проверка паритета dev-workflow (nodemon/watcher).** Убедиться, что после перехода на `tsx watch` опыт разработки не хуже текущего `watcher.js` + nodemon:
|
||||
- **Hot-reload:** правка любого `.ts` в `src/**` вызывает автоматический перезапуск сервера; в консоли видны сообщения о рестарте (аналог `nodemon restarted due changes`).
|
||||
- **Логи в реальном времени:** `console.*`/логи приложения стримятся в stdout сразу, без буферизации; проверить, что вывод не теряется при рестарте (`tsx watch` не глушит stdout дочернего процесса).
|
||||
- **Миграции в dev:** новые миграции применяются ожидаемым образом. Текущий `watcher.js` авто-применял новые файлы в `db/migrations`/`db/seeders` «на лету» через chokidar; план заменяет это на `predev`-шаг `db:migrate`. Подтвердить, что выбранный сценарий (ручной `npm run db:migrate` при добавлении миграции **или** сохранённый отдельный chokidar-вотчер) задокументирован и работает. Если авто-применение «на лету» нужно — оставить лёгкий watcher, запускающий `tsx src/db/migrate.ts up` на событие `add`.
|
||||
- **Завершение процесса:** `Ctrl+C` корректно останавливает `tsx watch` и дочерний сервер (нет «зависших» процессов на порту).
|
||||
5. Обновить `docs/full-integration-refactor-plan.md` (раздел Backend baseline): добавить `typecheck`, `build`, тесты.
|
||||
6. Финальный гейт: `typecheck` + `lint` + `vitest` + `build` + ручной прогон миграций на чистой БД + подтверждённый паритет dev-workflow (п.4).
|
||||
|
||||
---
|
||||
|
||||
## 5. Типизация: где основной объём
|
||||
|
||||
| Слой | Файлов | Сложность типизации |
|
||||
|---|---|---|
|
||||
| `db/models` | 39 | Высокая — атрибуты, creation-атрибуты, связи |
|
||||
| `db/api` | 28 | Средняя — DTO входа/выхода, опции транзакций |
|
||||
| `routes` | 45 | Средняя — Request/Response, currentUser, обёртки |
|
||||
| `services` | 48 | Средняя — бизнес-DTO, ошибки |
|
||||
| `constants` | 13 | Низкая |
|
||||
| прочее (auth, middlewares, config, ai, helpers) | ~10 | Низкая–средняя |
|
||||
|
||||
Основной риск и трудозатраты сосредоточены в `db/` (модели + api). Рекомендуется выделить
|
||||
типизацию моделей в отдельный поток работ и при необходимости распараллелить по доменам
|
||||
(люди/учёба/посещаемость/финансы/контент).
|
||||
|
||||
---
|
||||
|
||||
## 6. Риски и меры
|
||||
|
||||
- **Переход миграций на Umzug** — главный риск. Меры: использовать `SequelizeStorage` с той же таблицей `SequelizeMeta`, чтобы история уже применённых миграций сохранилась; обязательно прогнать раннер и на чистой БД, и на БД с существующей историей; новые миграции — `.ts`, старые — оставить `.cjs` без переписывания. (Базовый механизм подтверждён spike: Umzug 3.8.3 + tsx выполняют `.ts`-миграции.)
|
||||
- **Разрастание `any` под давлением сроков** — запрещено `CLAUDE.md`. Мера: `typecheck` как блокирующий гейт; временно сложные места изолировать узкими типами, а не `any`.
|
||||
- **Расхождение dev (`tsx`) и прод (`dist`)** — алиасы/расширения резолвятся по-разному. Мера: всегда проверять и `npm run dev`, и `node dist/index.js` в CI.
|
||||
- **Связи Sequelize теряют типы** при `define()`-подходе. Мера: общий тип реестра `Db` + строго типизированный `associate`.
|
||||
- **swagger пути** ломаются после сборки. Мера: env-зависимый `apis`-glob, проверка `/api-docs` в смоуке.
|
||||
- **Большой diff** усложняет ревью. Мера: PR на каждый связный блок (по разделам Фазы A), а не одним коммитом.
|
||||
|
||||
## 7. Стратегия отката
|
||||
|
||||
- Фаза A полностью обратима: TS компилируется в тот же CommonJS; при проблеме можно остановиться на любом блоке — смешанная кодовая база рабочая.
|
||||
- Фаза B — отдельная ветка/серия PR; точка возврата — последний зелёный коммит Фазы A.
|
||||
- Содержимое уже применённых миграций не меняется; Umzug читает ту же `SequelizeMeta`, поэтому история БД не затрагивается. Откат миграционного слоя = вернуть прежний `db/reset.js`/CLI-вызовы.
|
||||
|
||||
## 8. Порядок исполнения (сводка)
|
||||
|
||||
Фаза 0 (инструменты) → Фаза A (TS, снизу вверх: constants → config → helpers/utils → models →
|
||||
db/api → services → auth/middlewares → ai → routes → index) → Фаза B (ESM: tsconfig NodeNext,
|
||||
`type:module`, синтаксис import/export, import.meta.url, загрузчик моделей, Umzug-миграции,
|
||||
swagger, tsx) → Фаза C (скрипты/Docker/Node 24) → Фаза D (тесты/верификация/доки).
|
||||
|
||||
> Оценка объёма намеренно не дана в часах: основной труд — типизация `db/` (~61 файл) и `routes`
|
||||
> (45 файлов). После готовности каркаса (Фаза 0 + загрузчик моделей) остальное — предсказуемая
|
||||
> механическая работа поблочно.
|
||||
|
||||
---
|
||||
|
||||
## 9. Открытые вопросы
|
||||
|
||||
Все вопросы по состоянию на 2026-06-09 закрыты — см. раздел 0 «Принятые решения».
|
||||
Остаётся один пункт к проверке в коде по ходу работ: подтвердить замену `sequelize-json-schema`
|
||||
при типизации `routes/openai.ts` (раздел 10.3).
|
||||
|
||||
---
|
||||
|
||||
## 10. Аудит зависимостей: обновление и замена
|
||||
|
||||
Версии проверены по npm-реестру на 2026-06-09 (не из памяти модели). Источник истины —
|
||||
реестр; несколько пакетов в `package.json` (например, `lodash`, `nodemailer`, `cors`, `eslint`)
|
||||
уже соответствуют последним стабильным релизам в текущем реестре, менять их не нужно.
|
||||
|
||||
### 10.1. Удалить (не используются в коде)
|
||||
|
||||
| Пакет | Версия | Причина |
|
||||
|---|---|---|
|
||||
| `mysql2` | 3.22.5 | Диалект БД везде `postgres`, 0 использований |
|
||||
| `tedious` | 19.2.1 | MSSQL-драйвер, 0 использований |
|
||||
| `sqlite` | 5.1.1 | 0 использований |
|
||||
| `lodash` | 4.18.1 | Единственный импорт `lodash/get` в `services/notifications/helpers.js` — заменяется на optional chaining (`?.`) |
|
||||
|
||||
Эффект: меньше поверхности атаки, легче и быстрее установка/сборка. Оставляем только драйвер `pg` + `pg-hstore`.
|
||||
|
||||
### 10.2. Заменить — deprecated / устаревшие
|
||||
|
||||
| Текущий | Статус | Рекомендуемая замена | Масштаб |
|
||||
|---|---|---|---|
|
||||
| `json2csv` 5.0.7 | **deprecated** («Package no longer supported») | `@json2csv/plainjs` 7.0.6 (официальный преемник, scoped-пакеты) | 26 файлов |
|
||||
| `moment` 2.30.1 | Maintenance mode, проект сам рекомендует альтернативы | **`dayjs` 1.11.21** (решено: минимальный diff, близкий API) | 26 файлов |
|
||||
| `body-parser` (в `index.js`) | Избыточен в Express 5 | Встроенный `express.json()` | 1 файл |
|
||||
| `formidable` 3.5.4 | Дублирует `multer` | Консолидировать на `multer` 2.1.1 (популярнее, активнее), переписать `services/file.js` | 1 файл |
|
||||
| `sequelize-cli` 6.6.5 (devDep) | Не поддерживает `.ts`-миграции | **Umzug 3.8.3 + tsx** (раздел 11) | весь миграционный флоу |
|
||||
|
||||
Выбор `dayjs` зафиксирован (решение 0.1).
|
||||
|
||||
### 10.3. Заменить — опционально (низкая поддержка)
|
||||
|
||||
| Текущий | Последняя публикация | Вариант | Комментарий |
|
||||
|---|---|---|---|
|
||||
| `passport-google-oauth2` 0.2.0 | 2022 | `passport-google-oauth20` 2.0.0 или `openid-client` 6.8.4 | Низкая активность; `passport-google-oauth20` — почти drop-in, `openid-client` — стратегически современнее, но больше работы |
|
||||
| `passport-microsoft` 2.1.0 | 2024 | оставить либо `openid-client` 6.8.4 | Поддержка приемлема; единый `openid-client` под оба провайдера — отдельная задача |
|
||||
| `sequelize-json-schema` 2.1.1 | 2022 | `zod-to-json-schema` 3.25.2 или ручная схема | Используется в 1 файле (`routes/openai.js`); пересмотреть при типизации этого роута |
|
||||
|
||||
**Решено (0.7):** OAuth-замена выносится в **отдельную задачу** после основной миграции (внесена
|
||||
в `docs/full-integration-refactor-plan.md`), чтобы не смешивать риски (изменение потока
|
||||
аутентификации ≠ смена языка/модулей).
|
||||
|
||||
### 10.4. Обновить версии (минор/патч, без замены)
|
||||
|
||||
`@google-cloud/storage` 7.19 → 7.21. Остальные ключевые рантайм-пакеты уже на актуальных стабильных:
|
||||
`express` 5.2.1, `helmet` 8.2.0, `axios` 1.17.0, `bcrypt` 6.0.0, `jsonwebtoken` 9.0.3,
|
||||
`pg` 8.21.0, `pg-hstore` 2.3.4, `cors` 2.8.6, `passport` 0.7.0, `passport-jwt` 4.0.1,
|
||||
`csv-parser` 3.2.1, `swagger-jsdoc` 6.3.0, `swagger-ui-express` 5.0.1, `chokidar` 5.0.0,
|
||||
`nodemailer` 8.0.10, `multer` 2.1.1.
|
||||
|
||||
Перед общим обновлением выполнить `npm outdated` и `npm audit`, фиксировать точные версии в lock-файле.
|
||||
|
||||
### 10.5. Sequelize — остаёмся на 6 (важно)
|
||||
|
||||
`sequelize` стабильный — **6.37.8**; версия 7 существует только как **alpha** (`7.0.0-alpha.9`).
|
||||
Пользователь просил **стабильные** версии, поэтому переход на Sequelize 7 (TS-native) сейчас
|
||||
**не делаем**. ORM как таковой не меняем: переписывание моделей под другой ORM (Drizzle/Prisma)
|
||||
противоречит принципу «минимальные изменения» и выходит за рамки миграции на TS/ESM. Это
|
||||
возможная отдельная инициатива в будущем, но не часть данного плана.
|
||||
|
||||
### 10.6. Новые зависимости для TS/ESM (актуальные версии)
|
||||
|
||||
- Рантайм: `umzug` 3.8.3 (миграции, раздел 11).
|
||||
- Dev: `typescript` 6.0.3, `tsx` 4.22.4, `tsc-alias` 1.8.17, `vitest` 4.1.8, `typescript-eslint` 8.61.0, `@types/node` 25.9.2, `@types/express` 5.0.6, `@types/multer` 2.1.0, и типы для остальных библиотек без собственных деклараций (см. Фаза 0, п. 1).
|
||||
- **Не добавлять:** `@types/lodash` (`lodash` удаляется); `@types/passport-google-oauth20` (OAuth-замена отложена).
|
||||
|
||||
Целевая среда исполнения — **Node 24 (Active LTS)**: удовлетворяет самым строгим `engines.node`
|
||||
зависимостей (`vitest` и `eslint` требуют `>=24`).
|
||||
|
||||
### 10.7. Порядок применения
|
||||
|
||||
1. Сначала **удаления** (10.1) — самый безопасный шаг, не меняет поведение.
|
||||
2. Затем **замены** (10.2) — модуль за модулем, вместе с типизацией соответствующих файлов в Фазе A.
|
||||
3. **Опциональные** замены (10.3) — после стабилизации основной миграции, отдельными задачами.
|
||||
4. **Минорные апдейты** (10.4) — единым PR с `npm audit` перед финальной верификацией (Фаза D).
|
||||
|
||||
---
|
||||
|
||||
## 11. Лучшие практики: TS-миграции на Umzug
|
||||
|
||||
Umzug — библиотека-движок, на которой построен и сам sequelize-cli, поэтому переход совместим
|
||||
по хранилищу истории. Подход (подтверждён spike: Umzug 3.8.3 + tsx исполняют `.ts`-миграции):
|
||||
|
||||
1. **Единое хранилище истории.** Использовать `SequelizeStorage` с той же таблицей `SequelizeMeta`, что вёл sequelize-cli. Уже применённые миграции остаются зарегистрированными и повторно не запускаются — история БД не теряется.
|
||||
2. **Сосуществование старого и нового.** Glob включает и `.cjs` (15 существующих миграций — не переписываем), и `.ts` (все новые). `tsx` грузит оба формата.
|
||||
3. **Типизированные миграции.** Каждая новая миграция — `.ts` с типизированным контекстом `QueryInterface`:
|
||||
|
||||
```ts
|
||||
// src/db/migrations/2026XXXX-create-foo.ts
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export async function up({ context: qi }: { context: QueryInterface }) {
|
||||
await qi.createTable('foo', {
|
||||
id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
|
||||
});
|
||||
}
|
||||
export async function down({ context: qi }: { context: QueryInterface }) {
|
||||
await qi.dropTable('foo');
|
||||
}
|
||||
```
|
||||
|
||||
4. **Раннер с CLI.** Один файл `src/db/migrate.ts` создаёт `Umzug` и пробрасывает CLI через `umzug.runAsCLI()` (даёт команды `up`/`down`/`pending`/`executed`):
|
||||
|
||||
```ts
|
||||
// src/db/migrate.ts
|
||||
import { Umzug, SequelizeStorage } from 'umzug';
|
||||
import { sequelize } from '@/db/models'; // существующий инстанс Sequelize
|
||||
|
||||
export const migrator = new Umzug({
|
||||
migrations: { glob: 'src/db/migrations/*.{cjs,ts}' },
|
||||
context: sequelize.getQueryInterface(),
|
||||
storage: new SequelizeStorage({ sequelize }), // таблица SequelizeMeta
|
||||
logger: console,
|
||||
});
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
migrator.runAsCLI();
|
||||
}
|
||||
```
|
||||
|
||||
5. **Скрипты:** `db:migrate` = `tsx src/db/migrate.ts up`, `db:rollback` = `... down`, `db:status` = `... pending`. В Dockerfile/прод миграции запускать перед стартом (`tsx` ставить в рантайм-зависимости либо запускать из собранного `dist`).
|
||||
6. **Сидеры.** Второй экземпляр Umzug со своей meta-таблицей (`SequelizeMeta_seeders`) и аналогичным раннером `src/db/seed.ts`; существующие 5 сидеров перенести как `.cjs` (не переписывать) или по необходимости — на `.ts`.
|
||||
7. **Замена `watcher.js`.** Авто-миграции в dev — через `predev`-шаг (`tsx src/db/migrate.ts up`), а не chokidar-вотчер; перезапуск сервера — `tsx watch`.
|
||||
8. **Проверка перед мерджем.** Прогнать раннер дважды: на чистой БД (все миграции применяются по порядку) и на БД с уже заполненной `SequelizeMeta` (применяются только новые).
|
||||
@ -2,33 +2,85 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
`user_progress` stores current-user progress for narrow staff workflows such as learned sign language items and zone check-ins. The backend owns tenant scope, user ownership, and persistence.
|
||||
`user_progress` stores per-user progress for narrow staff workflows, keyed by a typed
|
||||
`progress_type` and an `item_id`. Current supported types are `sign_learned` (sign-language items
|
||||
learned) and `zone_checkin` (zones-of-regulation check-ins). The backend owns tenant scope, user
|
||||
ownership, validation, and persistence (one row per user + type + item, upserted).
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/user_progress.ts` (thin wiring; `GET /`, `POST /`, `DELETE /by-item`).
|
||||
- Controller: `src/api/controllers/user_progress.controller.ts` (custom — not the CRUD factory).
|
||||
- Service (BLL): `src/services/user_progress.ts`.
|
||||
- Repository (DAL): queries run through `db.user_progress` inside the service (no separate
|
||||
`db/api/user_progress.ts`).
|
||||
- Model: `src/db/models/user_progress.ts`.
|
||||
- Shared used: `db/with-transaction.ts` (`withTransaction`); `services/shared/access.ts`
|
||||
(`assertAuthenticatedTenantUser`, `getCampusId`, `getOrganizationIdOrGlobal`, `requireUserId`);
|
||||
`shared/constants/user-progress.ts` (`USER_PROGRESS_TYPE_VALUES`, `UserProgressType`);
|
||||
`shared/constants/pagination.ts` (`resolvePagination`); `shared/errors/validation.ts`
|
||||
(`ValidationError`).
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
All routes require JWT authentication. Base path mounted at `/api/user_progress`.
|
||||
|
||||
- `GET /api/user_progress?progress_type=<type>`: returns current user's progress rows for the requested type.
|
||||
- `GET /api/user_progress?progress_type=<type>&item_id=<id>`: returns current user's progress for one item.
|
||||
- `POST /api/user_progress`: creates or updates one progress item and returns the saved DTO.
|
||||
- `DELETE /api/user_progress/by-item?progress_type=<type>&item_id=<id>`: deletes current user's progress for one item.
|
||||
- `GET /api/user_progress` -> `200` `{ rows, count }`. Required query `progress_type` (must be a
|
||||
valid type); optional `item_id`, plus `limit` / `page` (paginated via `resolvePagination`).
|
||||
Returns the current user's rows for that type, ordered by `createdAt` desc.
|
||||
- `POST /api/user_progress` -> `200`. Request body wrapped as `{ data: <UserProgressInput> }`.
|
||||
Creates or updates one progress item and returns the saved DTO.
|
||||
- `DELETE /api/user_progress/by-item` -> `200` `{ deletedCount }`. Required query `progress_type`
|
||||
and `item_id`. Deletes the current user's row for that type + item.
|
||||
|
||||
## Supported Initial Types
|
||||
## Access Rules
|
||||
|
||||
- `sign_learned`
|
||||
- `zone_checkin`
|
||||
- All operations require an authenticated tenant user (`assertAuthenticatedTenantUser`).
|
||||
- No additional role gating: every operation is bound to the current user. List, upsert, and delete
|
||||
are all filtered by `userId` (`requireUserId`), so a user only ever reads, writes, or deletes
|
||||
their own progress. Frontend-provided names or roles are not trusted for ownership.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
|
||||
filter; regular users are bound to their organization. All queries are still filtered by
|
||||
`userId` so each user only sees their own progress.
|
||||
- `userId` is required from the current user (`requireUserId`).
|
||||
- On upsert, `campusId` is set from `getCampusId`; `updatedById` from the current user, and
|
||||
`createdById` on create.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Required mutation fields:
|
||||
- Mutation input (`UserProgressInput`): `progress_type` (must be one of
|
||||
`USER_PROGRESS_TYPE_VALUES`: `sign_learned`, `zone_checkin`) and `item_id` (non-empty string) are
|
||||
required. Optional: `value`, `score`, `metadata`. Invalid input raises `ValidationError`.
|
||||
- On save, `value` is persisted only if a string (else `null`), `score` only if a number (else
|
||||
`null`), and `metadata` defaults to `null` when absent; `item_id` is trimmed.
|
||||
- DTO fields: `id`, `progress_type`, `item_id`, `value`, `score`, `metadata`, `organizationId`,
|
||||
`campusId`, `userId`, `createdAt`, `updatedAt`.
|
||||
- Model columns: `progress_type` (ENUM over `USER_PROGRESS_TYPE_VALUES`, not null), `item_id`
|
||||
(TEXT, not null), `value` (TEXT, nullable), `score` (INTEGER, nullable), `metadata` (JSONB,
|
||||
nullable), `importHash` (unique). Tenant/audit columns: `organizationId` (not null), `userId`
|
||||
(not null), `createdById` (not null), `campusId` (nullable), `updatedById` (nullable). The model
|
||||
is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`.
|
||||
- Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users (`user`,
|
||||
`createdBy`, `updatedBy`).
|
||||
|
||||
- `progress_type`
|
||||
- `item_id`
|
||||
## Behavior / Notes
|
||||
|
||||
Optional mutation fields:
|
||||
- `upsert` runs inside `withTransaction`: it looks up the existing row by `organizationId` +
|
||||
`userId` + `progress_type` + `item_id`, updating it if present, otherwise creating it.
|
||||
- `list` validates `progress_type` and is paginated with shared defaults.
|
||||
- `removeByItem` is a hard `destroy` call (no transaction wrapper) returning the number of rows
|
||||
deleted.
|
||||
|
||||
- `value`
|
||||
- `score`
|
||||
- `metadata`
|
||||
## Tests
|
||||
|
||||
The backend assigns `organizationId`, `campusId`, and `userId` from the authenticated user. Frontend-provided user names or roles are not trusted for ownership.
|
||||
None yet (no `user_progress` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/user-progress-integration.md`,
|
||||
`frontend/docs/sign-language-integration.md`, `frontend/docs/zones-of-regulation-integration.md`.
|
||||
- Related slices: `safety-quiz-results.md`, `personality-quiz-results.md`,
|
||||
`walkthrough-checkins.md`.
|
||||
|
||||
134
backend/docs/users.md
Normal file
134
backend/docs/users.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Users Backend
|
||||
|
||||
## Purpose
|
||||
|
||||
`users` is the identity slice: it manages user accounts, their assigned role (`app_role`),
|
||||
per-user custom permissions, organization membership, optional avatar files, and the
|
||||
email-action tokens used for verification and password reset. The slice is hand-written: creating
|
||||
a user (or bulk-importing users) triggers an invitation email containing a password-reset link.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/users.ts` (CRUD plus `bulk-import`, `count`, `autocomplete`, `deleteByIds`;
|
||||
applies `checkCrudPermissions('users')` to every route).
|
||||
- Controller: `src/api/controllers/users.controller.ts` (resolves the UI host from the request
|
||||
`Referer` for invitation links; handles CSV export and file upload).
|
||||
- Service (BLL): `src/services/users.ts` (invitation + bulk-import workflow; duplicate-email and
|
||||
self-delete guards; delegates email sending to `AuthService`).
|
||||
- Repository (DAL): `src/db/api/users.ts` (also exposes auth helpers: `findBy`,
|
||||
`findProfileById`, `createFromAuth`, `updatePassword`, token generation/lookup,
|
||||
`markEmailVerified`).
|
||||
- Model: `src/db/models/users.ts`.
|
||||
- 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`
|
||||
(`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`),
|
||||
`middlewares/upload.ts` (`processFile`), `middlewares/check-permissions.ts`,
|
||||
`shared/errors/validation.ts`.
|
||||
|
||||
## API
|
||||
|
||||
All routes are mounted under `/api/users` and require JWT authentication (`src/index.ts`). Every
|
||||
route passes `checkCrudPermissions('users')`, requiring the permission `${METHOD}_USERS`
|
||||
(see `permissions.md`); the middleware's self-access bypass also lets a user act on their own
|
||||
record when `req.params.id`/`req.body.id` equals their own id.
|
||||
|
||||
- `POST /api/users` -> `200` `true`. Request body: `{ data: <UserInput> }`. Creates the user then
|
||||
sends an invitation email.
|
||||
- `POST /api/users/bulk-import` -> `200` `true`. Multipart CSV upload; every row must carry an
|
||||
`email`. Missing file raises `ValidationError('importer.errors.invalidFileEmpty')`.
|
||||
- `PUT /api/users/:id` -> `200` `true`. The controller calls `Service.update(req.body.data,
|
||||
req.body.id, ...)` (reads `req.body.id`, not `req.params.id`).
|
||||
- `DELETE /api/users/:id` -> `200` `true`.
|
||||
- `POST /api/users/deleteByIds` -> `200` `true`. Request body: `{ data: string[] }`.
|
||||
- `GET /api/users` -> `200` `{ rows, count }`. When `?filetype=csv`, responds with a CSV
|
||||
attachment of fields `id, firstName, lastName, phoneNumber, email`.
|
||||
- `GET /api/users/count` -> `200` `{ rows: [], count }`.
|
||||
- `GET /api/users/autocomplete` -> `200` array of `{ id, label }`.
|
||||
- `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions, staff
|
||||
profile, custom permissions, organization).
|
||||
|
||||
## Access Rules
|
||||
|
||||
- CRUD is gated by `checkCrudPermissions('users')` (`${METHOD}_USERS`), or a matching custom
|
||||
per-user permission, or the self-access bypass.
|
||||
- `remove` adds explicit service-level guards beyond the permission check:
|
||||
- A user cannot delete themselves: `currentUser.id === id` raises
|
||||
`ValidationError('iam.errors.deletingHimself')`.
|
||||
- Only roles named `config.roles.admin` or `config.roles.super_admin` may delete a user;
|
||||
otherwise `ValidationError('errors.forbidden.message')`.
|
||||
- `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email
|
||||
(`iam.errors.emailRequired`).
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- `findAll` scopes to `currentUser.organizationId` only when the user has a loaded
|
||||
`organizations` association and an `organizationId`. `globalAccess` users
|
||||
(`currentUser.app_role.globalAccess`) have the org constraint removed and read across
|
||||
organizations.
|
||||
- On `create`, organization membership is set from `data.organizations` via `setOrganizations`.
|
||||
- On `update`, role/org/custom-permission associations are only changed when their respective
|
||||
fields are present in the input.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Model columns (`src/db/models/users.ts`): `id` (UUID PK), `firstName`, `lastName`, `phoneNumber`
|
||||
(text), `email` (text, `allowNull: false`), `disabled` (boolean, default false), `password`
|
||||
(text), `emailVerified` (boolean, default false), `emailVerificationToken` +
|
||||
`emailVerificationTokenExpiresAt`, `passwordResetToken` + `passwordResetTokenExpiresAt`,
|
||||
`provider` (text), `importHash` (unique), `organizationId`, `createdById`, `updatedById`,
|
||||
`createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete). A `beforeCreate`/`beforeUpdate`
|
||||
hook trims `email`/`firstName`/`lastName`; for non-local OAuth providers `beforeCreate` forces
|
||||
`emailVerified = true` and generates a random bcrypt password when none is supplied.
|
||||
|
||||
Associations: `belongsToMany permissions` as `custom_permissions` (and `custom_permissions_filter`
|
||||
for list filtering) through `usersCustom_permissionsPermissions`; `hasMany staff` as `staff_user`;
|
||||
`hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo
|
||||
organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` as
|
||||
`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`.
|
||||
|
||||
List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `password`,
|
||||
`emailVerificationToken`, `passwordResetToken`, `provider` (ILIKE);
|
||||
`emailVerificationTokenExpiresAtRange`, `passwordResetTokenExpiresAtRange`, `createdAtRange`;
|
||||
`active`, `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated);
|
||||
`organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus `field`/`sort`
|
||||
(default `createdAt desc`) and `limit`/`page`.
|
||||
|
||||
## Behavior / Notes
|
||||
|
||||
- Invitation workflow: after a successful `create` commit, `AuthService.sendPasswordResetEmail`
|
||||
is called with type `'invitation'` and the host resolved from the request `Referer`, sending the
|
||||
new user a `/password-reset?token=...` link. The token is generated by
|
||||
`UsersDBApi.generatePasswordResetToken` with `EMAIL_ACTION_TOKEN_BYTES`/`EMAIL_ACTION_TOKEN_TTL_MS`.
|
||||
- Bulk import: parses the uploaded CSV (`csv-parser`); requires every row to carry an `email`
|
||||
(else `ValidationError('importer.errors.userEmailMissing')`); `bulkCreate`s with
|
||||
`ignoreDuplicates: true` and staggered `createdAt` (`BULK_IMPORT_TIMESTAMP_STEP_MS`); attaches
|
||||
avatar files per row.
|
||||
- Note (inconsistency in source): both `create` and `bulkImport` accept a `sendInvitationEmails`
|
||||
flag (controller passes `true`). In `create`, emails are sent only when `sendInvitationEmails`
|
||||
is truthy (`if (!sendInvitationEmails) return;`), but in `bulkImport` the email loop runs only
|
||||
when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are
|
||||
emailed while bulk-imported users are not.
|
||||
- All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
|
||||
- `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, staff profile,
|
||||
custom permissions, organization) used by authentication/authorization; `findProfileById`
|
||||
returns the trimmed profile DTO for `GET /me`.
|
||||
|
||||
## Tests
|
||||
|
||||
None yet (no `users` unit/e2e test under `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- 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`).
|
||||
- Frontend / auth: `auth-profile.md` (the profile DTO produced by `findProfileById`, plus the
|
||||
invitation/password-reset email flow shared with `AuthService`).
|
||||
@ -2,44 +2,97 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
`walkthrough_checkins` stores structured classroom observation records for director-level users. The backend owns tenant scope, campus scope, creator ownership, validation, and role-gated access.
|
||||
`walkthrough_checkins` stores structured classroom-observation ("walk-through") records for
|
||||
director-level staff. Each record captures the observed teacher, classroom, observing director,
|
||||
date/time, seven rated categories with optional per-category comments, and overall notes. The
|
||||
backend owns tenant scope, campus scope, creator ownership, validation, and role-gated access.
|
||||
|
||||
## Slice Files (by layer)
|
||||
|
||||
- Route: `src/routes/walkthrough_checkins.ts` (thin wiring; `GET /`, `POST /`, `DELETE /:id`).
|
||||
- Controller: `src/api/controllers/walkthrough_checkins.controller.ts` (custom — not the CRUD
|
||||
factory; uses `paramStr` for the id param).
|
||||
- Service (BLL): `src/services/walkthrough_checkins.ts` (+ `walkthrough_checkins.types.ts` for
|
||||
`WalkthroughInput` / `WalkthroughFilter`).
|
||||
- Repository (DAL): queries run through `db.walkthrough_checkins` inside the service (no separate
|
||||
`db/api/walkthrough_checkins.ts`).
|
||||
- Model: `src/db/models/walkthrough_checkins.ts`.
|
||||
- Shared used: `services/shared/validate.ts` (`nullableString`); `db/with-transaction.ts`
|
||||
(`withTransaction`); `shared/constants/pagination.ts` (`resolvePagination`);
|
||||
`shared/constants/walkthrough.ts` (`WALKTHROUGH_MANAGER_ROLE_NAMES`,
|
||||
`WALKTHROUGH_TENANT_WIDE_ROLE_NAMES`); `services/shared/access.ts` (`getOrganizationIdOrGlobal`,
|
||||
`hasGlobalAccess`, `hasRoleAccess`, `requireUserId`); `shared/errors/*` (`ForbiddenError`,
|
||||
`ValidationError`). Note: the service defines a module-local `getCampusId` and `campusScope`
|
||||
(staff-profile campus only), not the shared access helpers.
|
||||
|
||||
## API
|
||||
|
||||
All routes require JWT authentication.
|
||||
All routes require JWT authentication. Base path mounted at `/api/walkthrough_checkins`.
|
||||
|
||||
- `GET /api/walkthrough_checkins`: returns check-ins visible to the current manager.
|
||||
- `GET /api/walkthrough_checkins?teacher_name=<name>`: filters visible check-ins by teacher name.
|
||||
- `POST /api/walkthrough_checkins`: creates one check-in for the current user's organization and campus.
|
||||
- `DELETE /api/walkthrough_checkins/:id`: deletes one visible check-in.
|
||||
- `GET /api/walkthrough_checkins` -> `200` `{ rows, count }`. Optional query `teacher_name`, plus
|
||||
`limit` / `page` (paginated via `resolvePagination`). Returns check-ins visible to the current
|
||||
manager, ordered by `check_in_date` desc then `createdAt` desc.
|
||||
- `POST /api/walkthrough_checkins` -> `201`. Request body wrapped as `{ data: <WalkthroughInput> }`.
|
||||
Returns the created check-in DTO.
|
||||
- `DELETE /api/walkthrough_checkins/:id` -> `200` `{ deletedCount }`. Deletes one check-in visible
|
||||
to the current manager.
|
||||
|
||||
## Access Rules
|
||||
|
||||
- Director/superintendent-capable generated roles can create, list, and delete check-ins.
|
||||
- Records are scoped to the current user's organization.
|
||||
- Campus-scoped users write records to their current campus.
|
||||
- The frontend does not send organization, campus, creator, or updater fields.
|
||||
- All operations require a role in `WALKTHROUGH_MANAGER_ROLE_NAMES` (`Super Administrator`,
|
||||
`Administrator`, `Platform Owner`, `Tenant Director`, `Campus Manager`) 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
|
||||
`delete` via `campusScope`.
|
||||
|
||||
## Tenant Scope
|
||||
|
||||
- Organization is resolved via `getOrganizationIdOrGlobal`: global access users bypass the org
|
||||
filter and see check-ins across all organizations; regular users are bound to their org.
|
||||
- `campusId` is resolved from the module-local `getCampusId` — the current staff profile's campus
|
||||
only (`currentUser.staff_user[0].campusId`), else `null`; it never falls back to the user's own
|
||||
`campusId`.
|
||||
- On create, `createdById` is required from the current user (`requireUserId`); `updatedById` from
|
||||
the current user.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Required mutation fields:
|
||||
- Required mutation fields: `teacher_name`, `classroom`, `director_name`, `check_in_date`,
|
||||
`check_in_time` (non-empty strings); and the seven rating fields `attitude_rating`,
|
||||
`classroom_management_rating`, `cleanliness_rating`, `vibes_rating`, `team_dynamics_rating`,
|
||||
`emergency_exit_rating`, `lesson_plan_rating` (finite numbers). Invalid input raises
|
||||
`ValidationError`.
|
||||
- Optional mutation fields: the matching per-category comments (`attitude_comment`,
|
||||
`classroom_management_comment`, `cleanliness_comment`, `vibes_comment`, `team_dynamics_comment`,
|
||||
`emergency_exit_comment`, `lesson_plan_comment`) and `overall_notes`. Comments are normalized via
|
||||
`nullableString` (trimmed, blank -> `null`).
|
||||
- DTO fields: `id`, the five required strings, the seven `*_rating` and matching `*_comment` fields,
|
||||
`overall_notes`, `organizationId`, `campusId`, `createdById`, `updatedById`, `createdAt`,
|
||||
`updatedAt`.
|
||||
- Model columns: the five required strings are TEXT not null except `check_in_date` (DATEONLY) and
|
||||
`check_in_time` (TIME); the seven `*_rating` are INTEGER not null; the seven `*_comment` and
|
||||
`overall_notes` are TEXT nullable; `importHash` (unique). Tenant/audit columns: `organizationId`
|
||||
(not null), `createdById` (not null), `campusId` (nullable), `updatedById` (nullable). The model
|
||||
is `paranoid` (soft delete via `deletedAt`) and uses `freezeTableName`.
|
||||
- Associations: `belongsTo` organizations (`organization`), campuses (`campus`), users
|
||||
(`createdBy`, `updatedBy`). Note: there is no `user` association on this model.
|
||||
|
||||
- `teacher_name`
|
||||
- `classroom`
|
||||
- `director_name`
|
||||
- `check_in_date`
|
||||
- `check_in_time`
|
||||
- `attitude_rating`
|
||||
- `classroom_management_rating`
|
||||
- `cleanliness_rating`
|
||||
- `vibes_rating`
|
||||
- `team_dynamics_rating`
|
||||
- `emergency_exit_rating`
|
||||
- `lesson_plan_rating`
|
||||
## Behavior / Notes
|
||||
|
||||
Optional mutation fields:
|
||||
- `create` runs inside `withTransaction`; required string fields are trimmed before persistence.
|
||||
- `list` is paginated with shared defaults (`resolvePagination`).
|
||||
- `remove` is a hard `destroy` scoped by `id` + organization + `campusScope`, returning the number
|
||||
of rows deleted (no transaction wrapper).
|
||||
|
||||
- category comments
|
||||
- `overall_notes`
|
||||
## Tests
|
||||
|
||||
The backend returns normalized DTO rows with tenant and audit fields.
|
||||
None yet (no `walkthrough_checkins` unit/e2e test in `src/`).
|
||||
|
||||
## Related
|
||||
|
||||
- Frontend: `frontend/docs/walkthrough-integration.md`.
|
||||
- Related slices: `frame-entries.md` (similar editor-role-gated, campus-resolved director
|
||||
workflow), `safety-quiz-results.md`, `personality-quiz-results.md`.
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
const eslint = require('@eslint/js');
|
||||
const importPlugin = require('eslint-plugin-import-x');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ['node_modules/**', 'tmp/**', 'logs/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
plugins: {
|
||||
'import-x': importPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
console: 'readonly',
|
||||
module: 'readonly',
|
||||
process: 'readonly',
|
||||
require: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
URL: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'import-x/no-unresolved': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
145
backend/eslint.config.ts
Normal file
145
backend/eslint.config.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import importPlugin from 'eslint-plugin-import-x';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'tmp/**',
|
||||
'logs/**',
|
||||
'scripts/**',
|
||||
// Generated/seed data run via tsx, not part of the typechecked sources.
|
||||
'src/db/migrations/**',
|
||||
'src/db/seeders/**',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
{
|
||||
// CommonJS scripts/config (.cjs) kept outside the ESM TypeScript sources.
|
||||
files: ['**/*.{js,cjs}'],
|
||||
plugins: {
|
||||
'import-x': importPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
console: 'readonly',
|
||||
module: 'readonly',
|
||||
process: 'readonly',
|
||||
require: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
URL: 'readonly',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
'import-x/resolver': {
|
||||
node: { extensions: ['.js', '.ts', '.json'] },
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'import-x/no-unresolved': 'error',
|
||||
},
|
||||
},
|
||||
// TypeScript: type-aware resolution is handled by tsc, not import-x.
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ['**/*.ts'],
|
||||
})),
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Layer import boundaries — hard invariants (see
|
||||
// backend/docs/backend-architecture-refactor-plan.md). Debt that is still
|
||||
// being paid down (API->DAL, BLL->HTTP) is ratcheted by the boundary test in
|
||||
// src/shared/architecture/import-boundaries.test.ts, not here.
|
||||
{
|
||||
files: ['src/db/models/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/services/*', '@/api/*', '@/routes/*', '@/middlewares/*'],
|
||||
message: 'DAL models must not import the BLL or API layers.',
|
||||
},
|
||||
{ group: ['express'], message: 'DAL models must not depend on Express.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/db/api/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/api/*', '@/routes/*', '@/middlewares/*'],
|
||||
message: 'The DAL must not import the API layer.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/shared/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@/api/*',
|
||||
'@/routes/*',
|
||||
'@/middlewares/*',
|
||||
'@/services/*',
|
||||
'@/db/*',
|
||||
],
|
||||
message:
|
||||
'Cross-cutting code (shared/*) must not import any layer.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Routes and controllers go through the BLL; they must not reach the DAL.
|
||||
files: ['src/routes/**/*.ts', 'src/api/controllers/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@/db/api/*', '@/db/models/*'],
|
||||
message:
|
||||
'The API layer must call a service (BLL), not the DAL directly.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
3165
backend/package-lock.json
generated
3165
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,31 +1,39 @@
|
||||
{
|
||||
"name": "schoolchainmanager",
|
||||
"description": "School Chain Manager - template backend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
||||
"start:prod": "node --enable-source-maps dist/index.js",
|
||||
"start:production": "npm run db:migrate:prod && npm run db:seed:prod && npm run start:prod",
|
||||
"db:migrate:prod": "node dist/db/umzug.js migrate:up",
|
||||
"db:seed:prod": "node dist/db/umzug.js seed:up",
|
||||
"lint": "eslint .",
|
||||
"db:migrate": "sequelize-cli db:migrate",
|
||||
"db:seed": "sequelize-cli db:seed:all",
|
||||
"db:drop": "sequelize-cli db:drop",
|
||||
"db:create": "sequelize-cli db:create",
|
||||
"watch": "node watcher.js"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test 'src/**/*.test.ts'",
|
||||
"verify": "npm run typecheck && npm run lint && npm test",
|
||||
"build": "tsc -p tsconfig.build.json && tsc-alias -f && node scripts/copy-assets.mjs",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"db:migrate": "tsx src/db/umzug.ts migrate:up",
|
||||
"db:migrate:undo": "tsx src/db/umzug.ts migrate:down",
|
||||
"db:migrate:pending": "tsx src/db/umzug.ts migrate:pending",
|
||||
"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",
|
||||
"watch": "tsx watcher.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"axios": "^1.17.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"cors": "2.8.6",
|
||||
"csv-parser": "^3.2.1",
|
||||
"express": "5.2.1",
|
||||
"formidable": "3.5.4",
|
||||
"helmet": "8.2.0",
|
||||
"json2csv": "^5.0.7",
|
||||
"json-2-csv": "^5.5.11",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lodash": "4.18.1",
|
||||
"moment": "2.30.1",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "3.22.5",
|
||||
"nodemailer": "8.0.10",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
@ -34,25 +42,38 @@
|
||||
"pg": "8.21.0",
|
||||
"pg-hstore": "2.3.4",
|
||||
"sequelize": "6.37.8",
|
||||
"sequelize-json-schema": "^2.1.1",
|
||||
"sqlite": "5.1.1",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tedious": "^19.2.1"
|
||||
"umzug": "^3.8.3",
|
||||
"uuid": "^14.0.0",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
"node": ">=24"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"jiti": "^2.7.0",
|
||||
"nodemon": "3.1.14",
|
||||
"sequelize-cli": "6.6.5"
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.0"
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/scripts/copy-assets.mjs
Normal file
23
backend/scripts/copy-assets.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
import { readdirSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// tsc only emits .ts; non-TS assets read at runtime via import.meta.url must be
|
||||
// copied into dist/ so the compiled build can find them.
|
||||
function copyDir(src, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||
const from = join(src, entry.name);
|
||||
const to = join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(from, to);
|
||||
} else {
|
||||
copyFileSync(from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const to = resolve(backendRoot, 'dist/services/email/htmlTemplates');
|
||||
copyDir(resolve(backendRoot, 'src/services/email/htmlTemplates'), to);
|
||||
console.log(`Copied email templates -> ${to}`);
|
||||
@ -1,484 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const { URL } = require("url");
|
||||
|
||||
let CONFIG_CACHE = null;
|
||||
|
||||
class LocalAIApi {
|
||||
static createResponse(params, options) {
|
||||
return createResponse(params, options);
|
||||
}
|
||||
|
||||
static request(pathValue, payload, options) {
|
||||
return request(pathValue, payload, options);
|
||||
}
|
||||
|
||||
static fetchStatus(aiRequestId, options) {
|
||||
return fetchStatus(aiRequestId, options);
|
||||
}
|
||||
|
||||
static awaitResponse(aiRequestId, options) {
|
||||
return awaitResponse(aiRequestId, options);
|
||||
}
|
||||
|
||||
static extractText(response) {
|
||||
return extractText(response);
|
||||
}
|
||||
|
||||
static decodeJsonFromResponse(response) {
|
||||
return decodeJsonFromResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
async function createResponse(params, options = {}) {
|
||||
const payload = { ...(params || {}) };
|
||||
|
||||
if (!Array.isArray(payload.input) || payload.input.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "input_missing",
|
||||
message: 'Parameter "input" is required and must be a non-empty array.',
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = config();
|
||||
if (!payload.model) {
|
||||
payload.model = cfg.defaultModel;
|
||||
}
|
||||
|
||||
const initial = await request(options.path, payload, options);
|
||||
if (!initial.success) {
|
||||
return initial;
|
||||
}
|
||||
|
||||
const data = initial.data;
|
||||
if (data && typeof data === "object" && data.ai_request_id) {
|
||||
const pollTimeout = Number(options.poll_timeout ?? 300);
|
||||
const pollInterval = Number(options.poll_interval ?? 5);
|
||||
return await awaitResponse(data.ai_request_id, {
|
||||
interval: pollInterval,
|
||||
timeout: pollTimeout,
|
||||
headers: options.headers,
|
||||
timeout_per_call: options.timeout,
|
||||
verify_tls: options.verify_tls,
|
||||
});
|
||||
}
|
||||
|
||||
return initial;
|
||||
}
|
||||
|
||||
async function request(pathValue, payload = {}, options = {}) {
|
||||
const cfg = config();
|
||||
const resolvedPath = pathValue || options.path || cfg.responsesPath;
|
||||
|
||||
if (!resolvedPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: "project_id_missing",
|
||||
message: "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!cfg.projectUuid) {
|
||||
return {
|
||||
success: false,
|
||||
error: "project_uuid_missing",
|
||||
message: "PROJECT_UUID is not defined; aborting AI request.",
|
||||
};
|
||||
}
|
||||
|
||||
const bodyPayload = { ...(payload || {}) };
|
||||
if (!bodyPayload.project_uuid) {
|
||||
bodyPayload.project_uuid = cfg.projectUuid;
|
||||
}
|
||||
|
||||
const url = buildUrl(resolvedPath, cfg.baseUrl);
|
||||
const timeout = resolveTimeout(options.timeout, cfg.timeout);
|
||||
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
|
||||
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
[cfg.projectHeader]: cfg.projectUuid,
|
||||
};
|
||||
if (Array.isArray(options.headers)) {
|
||||
for (const header of options.headers) {
|
||||
if (typeof header === "string" && header.includes(":")) {
|
||||
const [name, value] = header.split(":", 2);
|
||||
headers[name.trim()] = value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = JSON.stringify(bodyPayload);
|
||||
return sendRequest(url, "POST", body, headers, timeout, verifyTls);
|
||||
}
|
||||
|
||||
async function fetchStatus(aiRequestId, options = {}) {
|
||||
const cfg = config();
|
||||
if (!cfg.projectUuid) {
|
||||
return {
|
||||
success: false,
|
||||
error: "project_uuid_missing",
|
||||
message: "PROJECT_UUID is not defined; aborting status check.",
|
||||
};
|
||||
}
|
||||
|
||||
const statusPath = resolveStatusPath(aiRequestId, cfg);
|
||||
const url = buildUrl(statusPath, cfg.baseUrl);
|
||||
const timeout = resolveTimeout(options.timeout, cfg.timeout);
|
||||
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
|
||||
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
[cfg.projectHeader]: cfg.projectUuid,
|
||||
};
|
||||
if (Array.isArray(options.headers)) {
|
||||
for (const header of options.headers) {
|
||||
if (typeof header === "string" && header.includes(":")) {
|
||||
const [name, value] = header.split(":", 2);
|
||||
headers[name.trim()] = value.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sendRequest(url, "GET", null, headers, timeout, verifyTls);
|
||||
}
|
||||
|
||||
async function awaitResponse(aiRequestId, options = {}) {
|
||||
const timeout = Number(options.timeout ?? 300);
|
||||
const interval = Math.max(Number(options.interval ?? 5), 1);
|
||||
const deadline = Date.now() + Math.max(timeout, interval) * 1000;
|
||||
|
||||
while (true) {
|
||||
const statusResp = await fetchStatus(aiRequestId, {
|
||||
headers: options.headers,
|
||||
timeout: options.timeout_per_call,
|
||||
verify_tls: options.verify_tls,
|
||||
});
|
||||
|
||||
if (statusResp.success) {
|
||||
const data = statusResp.data || {};
|
||||
if (data && typeof data === "object") {
|
||||
if (data.status === "success") {
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
data: data.response || data,
|
||||
};
|
||||
}
|
||||
if (data.status === "failed") {
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
error: String(data.error || "AI request failed"),
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return statusResp;
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
return {
|
||||
success: false,
|
||||
error: "timeout",
|
||||
message: "Timed out waiting for AI response.",
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(interval * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(response) {
|
||||
const payload = response && typeof response === "object" ? response.data || response : null;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.output)) {
|
||||
let combined = "";
|
||||
for (const item of payload.output) {
|
||||
if (!item || !Array.isArray(item.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of item.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
block.type === "output_text" &&
|
||||
typeof block.text === "string" &&
|
||||
block.text.length > 0
|
||||
) {
|
||||
combined += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combined) {
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.choices &&
|
||||
payload.choices[0] &&
|
||||
payload.choices[0].message &&
|
||||
typeof payload.choices[0].message.content === "string"
|
||||
) {
|
||||
return payload.choices[0].message.content;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function decodeJsonFromResponse(response) {
|
||||
const text = extractText(response);
|
||||
if (!text) {
|
||||
throw new Error("No text found in AI response.");
|
||||
}
|
||||
|
||||
const parsed = parseJson(text);
|
||||
if (parsed.ok && parsed.value && typeof parsed.value === "object") {
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
const stripped = stripJsonFence(text);
|
||||
if (stripped !== text) {
|
||||
const parsedStripped = parseJson(stripped);
|
||||
if (parsedStripped.ok && parsedStripped.value && typeof parsedStripped.value === "object") {
|
||||
return parsedStripped.value;
|
||||
}
|
||||
throw new Error(`JSON parse failed after stripping fences: ${parsedStripped.error}`);
|
||||
}
|
||||
|
||||
throw new Error(`JSON parse failed: ${parsed.error}`);
|
||||
}
|
||||
|
||||
function config() {
|
||||
if (CONFIG_CACHE) {
|
||||
return CONFIG_CACHE;
|
||||
}
|
||||
|
||||
ensureEnvLoaded();
|
||||
|
||||
const baseUrl = process.env.AI_PROXY_BASE_URL || "https://flatlogic.com";
|
||||
const projectId = process.env.PROJECT_ID || null;
|
||||
let responsesPath = process.env.AI_RESPONSES_PATH || null;
|
||||
if (!responsesPath && projectId) {
|
||||
responsesPath = `/projects/${projectId}/ai-request`;
|
||||
}
|
||||
|
||||
const timeout = resolveTimeout(process.env.AI_TIMEOUT, 30);
|
||||
const verifyTls = resolveVerifyTls(process.env.AI_VERIFY_TLS, true);
|
||||
|
||||
CONFIG_CACHE = {
|
||||
baseUrl,
|
||||
responsesPath,
|
||||
projectId,
|
||||
projectUuid: process.env.PROJECT_UUID || null,
|
||||
projectHeader: process.env.AI_PROJECT_HEADER || "project-uuid",
|
||||
defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5-mini",
|
||||
timeout,
|
||||
verifyTls,
|
||||
};
|
||||
|
||||
return CONFIG_CACHE;
|
||||
}
|
||||
|
||||
function buildUrl(pathValue, baseUrl) {
|
||||
const trimmed = String(pathValue || "").trim();
|
||||
if (trimmed === "") {
|
||||
return baseUrl;
|
||||
}
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return `${baseUrl}${trimmed}`;
|
||||
}
|
||||
return `${baseUrl}/${trimmed}`;
|
||||
}
|
||||
|
||||
function resolveStatusPath(aiRequestId, cfg) {
|
||||
const basePath = (cfg.responsesPath || "").replace(/\/+$/, "");
|
||||
if (!basePath) {
|
||||
return `/ai-request/${encodeURIComponent(String(aiRequestId))}/status`;
|
||||
}
|
||||
const normalized = basePath.endsWith("/ai-request") ? basePath : `${basePath}/ai-request`;
|
||||
return `${normalized}/${encodeURIComponent(String(aiRequestId))}/status`;
|
||||
}
|
||||
|
||||
function sendRequest(urlString, method, body, headers, timeoutSeconds, verifyTls) {
|
||||
return new Promise((resolve) => {
|
||||
let targetUrl;
|
||||
try {
|
||||
targetUrl = new URL(urlString);
|
||||
} catch (err) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: "invalid_url",
|
||||
message: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isHttps = targetUrl.protocol === "https:";
|
||||
const requestFn = isHttps ? https.request : http.request;
|
||||
const options = {
|
||||
protocol: targetUrl.protocol,
|
||||
hostname: targetUrl.hostname,
|
||||
port: targetUrl.port || (isHttps ? 443 : 80),
|
||||
path: `${targetUrl.pathname}${targetUrl.search}`,
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
timeout: Math.max(Number(timeoutSeconds || 30), 1) * 1000,
|
||||
};
|
||||
if (isHttps) {
|
||||
options.rejectUnauthorized = Boolean(verifyTls);
|
||||
}
|
||||
|
||||
const req = requestFn(options, (res) => {
|
||||
let responseBody = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
responseBody += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
const status = res.statusCode || 0;
|
||||
const parsed = parseJson(responseBody);
|
||||
const payload = parsed.ok ? parsed.value : responseBody;
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
const result = {
|
||||
success: true,
|
||||
status,
|
||||
data: payload,
|
||||
};
|
||||
if (!parsed.ok) {
|
||||
result.json_error = parsed.error;
|
||||
}
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
parsed.ok && payload && typeof payload === "object"
|
||||
? String(payload.error || payload.message || "AI proxy request failed")
|
||||
: String(responseBody || "AI proxy request failed");
|
||||
|
||||
resolve({
|
||||
success: false,
|
||||
status,
|
||||
error: errorMessage,
|
||||
response: payload,
|
||||
json_error: parsed.ok ? undefined : parsed.error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error("request_timeout"));
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: "request_failed",
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return { ok: false, error: "empty_response" };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(value) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function stripJsonFence(text) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("```json")) {
|
||||
return trimmed.replace(/^```json/, "").replace(/```$/, "").trim();
|
||||
}
|
||||
if (trimmed.startsWith("```")) {
|
||||
return trimmed.replace(/^```/, "").replace(/```$/, "").trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function resolveTimeout(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value ?? fallback), 10);
|
||||
return Number.isNaN(parsed) ? Number(fallback) : parsed;
|
||||
}
|
||||
|
||||
function resolveVerifyTls(value, fallback) {
|
||||
if (value === undefined || value === null) {
|
||||
return Boolean(fallback);
|
||||
}
|
||||
return String(value).toLowerCase() !== "false" && String(value) !== "0";
|
||||
}
|
||||
|
||||
function ensureEnvLoaded() {
|
||||
if (process.env.PROJECT_UUID && process.env.PROJECT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const envPath = path.resolve(__dirname, "../../../../.env");
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(envPath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read executor .env: ${err.message}`);
|
||||
}
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
|
||||
continue;
|
||||
}
|
||||
const [rawKey, ...rest] = trimmed.split("=");
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const value = rest.join("=").trim().replace(/^['"]|['"]$/g, "");
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LocalAIApi,
|
||||
createResponse,
|
||||
request,
|
||||
fetchStatus,
|
||||
awaitResponse,
|
||||
extractText,
|
||||
decodeJsonFromResponse,
|
||||
};
|
||||
4
backend/src/api/controllers/academic_years.controller.ts
Normal file
4
backend/src/api/controllers/academic_years.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/academic_years';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'start_date', 'end_date'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/assessment_results';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'remarks', 'score'] });
|
||||
4
backend/src/api/controllers/assessments.controller.ts
Normal file
4
backend/src/api/controllers/assessments.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/assessments';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'instructions', 'max_score', 'assigned_at', 'due_at'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/attendance_records';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'remarks', 'minutes_late'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/attendance_sessions';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'notes', 'session_date'] });
|
||||
197
backend/src/api/controllers/auth.controller.ts
Normal file
197
backend/src/api/controllers/auth.controller.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { isRecord } from '@/shared/object';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import passport from 'passport';
|
||||
import config from '@/shared/config';
|
||||
import { queryStr } from '@/api/http/request';
|
||||
import AuthService from '@/services/auth';
|
||||
import ForbiddenError from '@/shared/errors/forbidden';
|
||||
import EmailSender from '@/services/email';
|
||||
import cookies from '@/auth/cookies';
|
||||
|
||||
/** Extracts the minimal session user from an OAuth principal (`{ user }`). */
|
||||
function readSessionUser(
|
||||
value: unknown,
|
||||
): { id: string; email: string; organizationId: string | null } | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const id = value.id;
|
||||
const email = value.email;
|
||||
const organizationId = value.organizationId;
|
||||
if (typeof id !== 'string' || typeof email !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
organizationId: typeof organizationId === 'string' ? organizationId : null,
|
||||
};
|
||||
}
|
||||
|
||||
function refererUrl(req: Request): URL {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
return new URL(referer);
|
||||
}
|
||||
|
||||
async function socialRedirect(
|
||||
req: Request,
|
||||
res: Response,
|
||||
principal: unknown,
|
||||
): Promise<void> {
|
||||
const socialUser = isRecord(principal) ? principal.user : undefined;
|
||||
const sessionUser = readSessionUser(socialUser);
|
||||
if (!sessionUser) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
const session = await AuthService.createSession(sessionUser, req);
|
||||
cookies.setSessionCookies(res, session);
|
||||
res.redirect(config.uiUrl);
|
||||
}
|
||||
|
||||
export async function signinLocal(req: Request, res: Response): Promise<void> {
|
||||
const result = await AuthService.signin(req.body.email, req.body.password);
|
||||
const session = await AuthService.createSession(result.user, req);
|
||||
cookies.setSessionCookies(res, session);
|
||||
const payload = await AuthService.currentUserProfile(result.user);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
const session = await AuthService.refreshSession(
|
||||
cookies.extractRefreshCookie(req) ?? undefined,
|
||||
req,
|
||||
);
|
||||
cookies.setSessionCookies(res, session);
|
||||
const payload = await AuthService.currentUserProfile(session.user);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function signout(req: Request, res: Response): Promise<void> {
|
||||
await AuthService.revokeSession(cookies.extractRefreshCookie(req) ?? undefined);
|
||||
cookies.clearSessionCookies(res);
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export async function me(req: Request, res: Response): Promise<void> {
|
||||
if (!req.currentUser || !req.currentUser.id) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
const payload = await AuthService.currentUserProfile(req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function passwordReset(req: Request, res: Response): Promise<void> {
|
||||
const payload = await AuthService.passwordReset(
|
||||
req.body.token,
|
||||
req.body.password,
|
||||
req,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function passwordUpdate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await AuthService.passwordUpdate(
|
||||
req.body.currentPassword,
|
||||
req.body.newPassword,
|
||||
req,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (!req.currentUser) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
await AuthService.sendEmailAddressVerificationEmail(
|
||||
req.currentUser.email ?? '',
|
||||
);
|
||||
res.status(200).send(true);
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const link = refererUrl(req);
|
||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.host);
|
||||
res.status(200).send(true);
|
||||
}
|
||||
|
||||
export async function signup(req: Request, res: Response): Promise<void> {
|
||||
const link = refererUrl(req);
|
||||
const result = await AuthService.signup(
|
||||
req.body.email,
|
||||
req.body.password,
|
||||
req.body.organizationId,
|
||||
req,
|
||||
link.host,
|
||||
);
|
||||
const session = await AuthService.createSession(result.user, req);
|
||||
cookies.setSessionCookies(res, session);
|
||||
const payload = await AuthService.currentUserProfile(result.user);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (!req.currentUser || !req.currentUser.id) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
await AuthService.updateProfile(req.body.profile, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
}
|
||||
|
||||
export async function verifyEmail(req: Request, res: Response): Promise<void> {
|
||||
const payload = await AuthService.verifyEmail(req.body.token, req);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export function emailConfigured(_req: Request, res: Response): void {
|
||||
res.status(200).send(EmailSender.isConfigured);
|
||||
}
|
||||
|
||||
export function googleSignin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
state: queryStr(req.query.app),
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
export async function googleCallback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): 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);
|
||||
}
|
||||
37
backend/src/api/controllers/campus_attendance.controller.ts
Normal file
37
backend/src/api/controllers/campus_attendance.controller.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import CampusAttendanceService from '@/services/campus_attendance';
|
||||
|
||||
export async function listConfigs(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CampusAttendanceService.listConfigs(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function upsertConfig(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CampusAttendanceService.upsertConfig(
|
||||
req.params.campusKey,
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function listSummaries(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CampusAttendanceService.listSummaries(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function upsertSummary(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CampusAttendanceService.upsertSummary(
|
||||
req.params.campusKey,
|
||||
req.params.date,
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
4
backend/src/api/controllers/campuses.controller.ts
Normal file
4
backend/src/api/controllers/campuses.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/campuses';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'code', 'address', 'phone', 'email'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/class_enrollments';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'enrolled_on', 'ended_on'] });
|
||||
4
backend/src/api/controllers/class_subjects.controller.ts
Normal file
4
backend/src/api/controllers/class_subjects.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/class_subjects';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id'] });
|
||||
4
backend/src/api/controllers/classes.controller.ts
Normal file
4
backend/src/api/controllers/classes.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/classes';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'section', 'capacity'] });
|
||||
40
backend/src/api/controllers/communications.controller.ts
Normal file
40
backend/src/api/controllers/communications.controller.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import CommunicationsService from '@/services/communications';
|
||||
|
||||
export async function listParentMessages(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await CommunicationsService.listParentMessages(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function createParentMessage(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await CommunicationsService.createParentMessage(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
|
||||
export async function listEvents(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CommunicationsService.listEvents(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function createEvent(req: Request, res: Response): Promise<void> {
|
||||
const payload = await CommunicationsService.createEvent(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
40
backend/src/api/controllers/content_catalog.controller.ts
Normal file
40
backend/src/api/controllers/content_catalog.controller.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import ContentCatalogService from '@/services/content_catalog';
|
||||
|
||||
export async function list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await ContentCatalogService.list(req.query, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
const payload = await ContentCatalogService.create(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
|
||||
export async function findManagedByType(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await ContentCatalogService.findManagedByType(
|
||||
req.params.contentType,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
const payload = await ContentCatalogService.update(
|
||||
req.params.contentType,
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
await ContentCatalogService.delete(req.params.contentType, req.currentUser);
|
||||
res.status(204).send();
|
||||
}
|
||||
89
backend/src/api/controllers/documents.controller.ts
Normal file
89
backend/src/api/controllers/documents.controller.ts
Normal file
@ -0,0 +1,89 @@
|
||||
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);
|
||||
}
|
||||
4
backend/src/api/controllers/fee_plans.controller.ts
Normal file
4
backend/src/api/controllers/fee_plans.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/fee_plans';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'notes', 'total_amount'] });
|
||||
28
backend/src/api/controllers/file.controller.ts
Normal file
28
backend/src/api/controllers/file.controller.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr } from '@/api/http/request';
|
||||
import services from '@/services/file';
|
||||
|
||||
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 {
|
||||
services.downloadLocal(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
export function upload(req: Request, res: Response): void {
|
||||
const fileName = `${paramStr(req.params.table)}/${paramStr(req.params.field)}`;
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
process.env.NEXT_PUBLIC_BACK_API
|
||||
) {
|
||||
void services.uploadGCloud(fileName, req, res);
|
||||
} else {
|
||||
void services.uploadLocal(fileName, {
|
||||
entity: null,
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
folderIncludesAuthenticationUid: false,
|
||||
})(req, res);
|
||||
}
|
||||
}
|
||||
25
backend/src/api/controllers/frame_entries.controller.ts
Normal file
25
backend/src/api/controllers/frame_entries.controller.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr } from '@/api/http/request';
|
||||
import FrameEntriesService from '@/services/frame_entries';
|
||||
|
||||
export async function list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await FrameEntriesService.list(req.query, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
const payload = await FrameEntriesService.create(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
|
||||
export async function update(req: Request, res: Response): Promise<void> {
|
||||
const payload = await FrameEntriesService.update(
|
||||
paramStr(req.params.id),
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
8
backend/src/api/controllers/grades.controller.ts
Normal file
8
backend/src/api/controllers/grades.controller.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import GradesService from '@/services/grades';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
const gradesController = createCrudController(GradesService, {
|
||||
csvFields: ['id', 'name', 'code', 'description', 'sort_order'],
|
||||
});
|
||||
|
||||
export default gradesController;
|
||||
4
backend/src/api/controllers/guardians.controller.ts
Normal file
4
backend/src/api/controllers/guardians.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/guardians';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'full_name', 'phone', 'email', 'address'] });
|
||||
4
backend/src/api/controllers/invoices.controller.ts
Normal file
4
backend/src/api/controllers/invoices.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
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'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/message_recipients';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'recipient_label', 'destination', 'delivered_at', 'read_at'] });
|
||||
4
backend/src/api/controllers/messages.controller.ts
Normal file
4
backend/src/api/controllers/messages.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/messages';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'subject', 'body', 'sent_at'] });
|
||||
4
backend/src/api/controllers/organizations.controller.ts
Normal file
4
backend/src/api/controllers/organizations.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/organizations';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name'] });
|
||||
4
backend/src/api/controllers/payments.controller.ts
Normal file
4
backend/src/api/controllers/payments.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
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'] });
|
||||
70
backend/src/api/controllers/permissions.controller.ts
Normal file
70
backend/src/api/controllers/permissions.controller.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr, queryNum, queryStr } from '@/api/http/request';
|
||||
import { toCsv } from '@/shared/csv';
|
||||
import Service from '@/services/permissions';
|
||||
import processFile from '@/middlewares/upload';
|
||||
import ValidationError from '@/shared/errors/validation';
|
||||
|
||||
const CSV_FIELDS = ['id', 'name'];
|
||||
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
await Service.create(req.body.data, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
}
|
||||
|
||||
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> {
|
||||
await Service.update(req.body.data, req.body.id, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
}
|
||||
|
||||
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, req.currentUser);
|
||||
|
||||
if (req.query.filetype === 'csv') {
|
||||
const csv = toCsv(payload.rows, CSV_FIELDS);
|
||||
res.status(200).attachment(csv);
|
||||
res.send(csv);
|
||||
} else {
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function count(req: Request, res: Response): Promise<void> {
|
||||
const payload = await Service.count(req.query, 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),
|
||||
);
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import PersonalityQuizResultsService from '@/services/personality_quiz_results';
|
||||
|
||||
export async function getCurrentUserResult(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await PersonalityQuizResultsService.getCurrentUserResult(
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function upsertCurrentUserResult(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const payload = await PersonalityQuizResultsService.upsertCurrentUserResult(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function distribution(req: Request, res: Response): Promise<void> {
|
||||
const payload = await PersonalityQuizResultsService.distribution(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import CampusCatalogService from '@/services/campus_catalog';
|
||||
|
||||
export async function listActive(_req: Request, res: Response): Promise<void> {
|
||||
const payload = await CampusCatalogService.listActive();
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr } from '@/api/http/request';
|
||||
import ContentCatalogService from '@/services/content_catalog';
|
||||
|
||||
export async function findByType(req: Request, res: Response): Promise<void> {
|
||||
const payload = await ContentCatalogService.findByType(
|
||||
paramStr(req.params.contentType),
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
4
backend/src/api/controllers/roles.controller.ts
Normal file
4
backend/src/api/controllers/roles.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/roles';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name'] });
|
||||
@ -0,0 +1,18 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import SafetyQuizResultsService from '@/services/safety_quiz_results';
|
||||
|
||||
export async function list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await SafetyQuizResultsService.list(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function create(req: Request, res: Response): Promise<void> {
|
||||
const payload = await SafetyQuizResultsService.create(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(201).send(payload);
|
||||
}
|
||||
26
backend/src/api/controllers/search.controller.ts
Normal file
26
backend/src/api/controllers/search.controller.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import logger from '@/shared/logger';
|
||||
import SearchService from '@/services/search';
|
||||
|
||||
export async function search(req: Request, res: Response): Promise<void> {
|
||||
const { searchQuery, organizationId } = req.body;
|
||||
const globalAccess = req.currentUser?.app_role?.globalAccess ?? false;
|
||||
|
||||
if (!searchQuery) {
|
||||
res.status(400).json({ error: 'Please enter a search query' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const foundMatches = await SearchService.search(
|
||||
searchQuery,
|
||||
req.currentUser,
|
||||
organizationId,
|
||||
globalAccess,
|
||||
);
|
||||
res.json(foundMatches);
|
||||
} catch (error) {
|
||||
logger.error('Internal Server Error', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
130
backend/src/api/controllers/shared/crud-controller.ts
Normal file
130
backend/src/api/controllers/shared/crud-controller.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { paramStr, queryNum, queryStr } from '@/api/http/request';
|
||||
import { toCsv } from '@/shared/csv';
|
||||
import processFile from '@/middlewares/upload';
|
||||
import ValidationError from '@/shared/errors/validation';
|
||||
|
||||
type CurrentUserArg = Request['currentUser'];
|
||||
|
||||
/**
|
||||
* The slice of a generic-CRUD service the controller calls. `CreateData`/
|
||||
* `UpdateData` are inferred from the service (the controller feeds them `any`
|
||||
* from `req.body`); list/count take the raw query, which the entity filters
|
||||
* already accept.
|
||||
*/
|
||||
export interface CrudControllerService<CreateData, UpdateData> {
|
||||
create(data: CreateData, currentUser?: CurrentUserArg): Promise<void>;
|
||||
bulkImport(fileBuffer: Buffer, currentUser?: CurrentUserArg): Promise<void>;
|
||||
update(
|
||||
data: UpdateData,
|
||||
id: string,
|
||||
currentUser?: CurrentUserArg,
|
||||
): Promise<unknown>;
|
||||
remove(id: string, currentUser?: CurrentUserArg): Promise<void>;
|
||||
deleteByIds(ids: string[], currentUser?: CurrentUserArg): Promise<void>;
|
||||
list(
|
||||
filter: Request['query'],
|
||||
globalAccess: boolean,
|
||||
currentUser?: CurrentUserArg,
|
||||
): Promise<{ rows: object[]; count: number }>;
|
||||
count(
|
||||
filter: Request['query'],
|
||||
globalAccess: boolean,
|
||||
currentUser?: CurrentUserArg,
|
||||
): Promise<unknown>;
|
||||
autocomplete(
|
||||
query: string | undefined,
|
||||
limit: number | undefined,
|
||||
offset: number | undefined,
|
||||
globalAccess: boolean,
|
||||
organizationId?: string,
|
||||
): Promise<unknown>;
|
||||
findById(id: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
function globalAccessOf(req: Request): boolean {
|
||||
return req.currentUser?.app_role?.globalAccess ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the standard 9 generic-CRUD HTTP handlers (API layer) for a service.
|
||||
* `csvFields` selects the columns the `?filetype=csv` list export emits.
|
||||
*/
|
||||
export function createCrudController<CreateData, UpdateData>(
|
||||
service: CrudControllerService<CreateData, UpdateData>,
|
||||
{ csvFields }: { csvFields: string[] },
|
||||
) {
|
||||
return {
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
await service.create(req.body.data, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
|
||||
async 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);
|
||||
},
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
await service.update(req.body.data, req.body.id, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
|
||||
async remove(req: Request, res: Response): Promise<void> {
|
||||
await service.remove(paramStr(req.params.id), req.currentUser);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
|
||||
async deleteByIds(req: Request, res: Response): Promise<void> {
|
||||
await service.deleteByIds(req.body.data, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
|
||||
async list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await service.list(
|
||||
req.query,
|
||||
globalAccessOf(req),
|
||||
req.currentUser,
|
||||
);
|
||||
|
||||
if (req.query.filetype === 'csv') {
|
||||
const csv = toCsv(payload.rows, csvFields);
|
||||
res.status(200).attachment(csv);
|
||||
res.send(csv);
|
||||
} else {
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
},
|
||||
|
||||
async count(req: Request, res: Response): Promise<void> {
|
||||
const payload = await service.count(
|
||||
req.query,
|
||||
globalAccessOf(req),
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
},
|
||||
|
||||
async 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);
|
||||
},
|
||||
|
||||
async findById(req: Request, res: Response): Promise<void> {
|
||||
const payload = await service.findById(paramStr(req.params.id));
|
||||
res.status(200).send(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CrudController = ReturnType<typeof createCrudController>;
|
||||
4
backend/src/api/controllers/staff.controller.ts
Normal file
4
backend/src/api/controllers/staff.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/staff';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'employee_number', 'job_title', 'hire_date'] });
|
||||
18
backend/src/api/controllers/staff_attendance.controller.ts
Normal file
18
backend/src/api/controllers/staff_attendance.controller.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import StaffAttendanceService from '@/services/staff_attendance';
|
||||
|
||||
export async function listRecords(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAttendanceService.listRecords(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function summary(req: Request, res: Response): Promise<void> {
|
||||
const payload = await StaffAttendanceService.summary(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
4
backend/src/api/controllers/students.controller.ts
Normal file
4
backend/src/api/controllers/students.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
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'] });
|
||||
4
backend/src/api/controllers/subjects.controller.ts
Normal file
4
backend/src/api/controllers/subjects.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/subjects';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'code', 'description'] });
|
||||
@ -0,0 +1,4 @@
|
||||
import service from '@/services/timetable_periods';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'room', 'starts_at', 'ends_at'] });
|
||||
4
backend/src/api/controllers/timetables.controller.ts
Normal file
4
backend/src/api/controllers/timetables.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import service from '@/services/timetables';
|
||||
import { createCrudController } from '@/api/controllers/shared/crud-controller';
|
||||
|
||||
export default createCrudController(service, { csvFields: ['id', 'name', 'effective_from', 'effective_to'] });
|
||||
23
backend/src/api/controllers/user_progress.controller.ts
Normal file
23
backend/src/api/controllers/user_progress.controller.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import UserProgressService from '@/services/user_progress';
|
||||
|
||||
export async function list(req: Request, res: Response): Promise<void> {
|
||||
const payload = await UserProgressService.list(req.query, req.currentUser);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function upsert(req: Request, res: Response): Promise<void> {
|
||||
const payload = await UserProgressService.upsert(
|
||||
req.body.data,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
|
||||
export async function removeByItem(req: Request, res: Response): Promise<void> {
|
||||
const payload = await UserProgressService.removeByItem(
|
||||
req.query,
|
||||
req.currentUser,
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user