diff --git a/.dockerignore b/.dockerignore index 2c83cc6..26dd1ab 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,8 @@ backend/node_modules +backend/dist +backend/public frontend/node_modules -frontend/build +frontend/dist +**/.env +**/.git +**/*.log diff --git a/CLAUDE.md b/CLAUDE.md index 0f47513..888f0ff 100644 --- a/CLAUDE.md +++ b/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// (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____`: 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) diff --git a/Dockerfile b/Dockerfile index 970d54f..a70b21e 100644 --- a/Dockerfile +++ b/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"] diff --git a/Dockerfile.dev b/Dockerfile.dev index a8353d5..07bc689 100644 --- a/Dockerfile.dev +++ b/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"] \ No newline at end of file +# 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;'"] diff --git a/backend/.env b/backend/.env index 25241b7..61c7b01 100644 --- a/backend/.env +++ b/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 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..4bf7b95 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.tsbuildinfo diff --git a/backend/.sequelizerc b/backend/.sequelizerc deleted file mode 100644 index fe89188..0000000 --- a/backend/.sequelizerc +++ /dev/null @@ -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") -}; \ No newline at end of file diff --git a/backend/docs/academic_years.md b/backend/docs/academic_years.md new file mode 100644 index 0000000..868934a --- /dev/null +++ b/backend/docs/academic_years.md @@ -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`. diff --git a/backend/docs/assessment_results.md b/backend/docs/assessment_results.md new file mode 100644 index 0000000..90b6001 --- /dev/null +++ b/backend/docs/assessment_results.md @@ -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`. diff --git a/backend/docs/assessments.md b/backend/docs/assessments.md new file mode 100644 index 0000000..7ee3ae9 --- /dev/null +++ b/backend/docs/assessments.md @@ -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`. diff --git a/backend/docs/attendance_records.md b/backend/docs/attendance_records.md new file mode 100644 index 0000000..fd210cb --- /dev/null +++ b/backend/docs/attendance_records.md @@ -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`. diff --git a/backend/docs/attendance_sessions.md b/backend/docs/attendance_sessions.md new file mode 100644 index 0000000..86ed9c6 --- /dev/null +++ b/backend/docs/attendance_sessions.md @@ -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`. diff --git a/backend/docs/auth-profile.md b/backend/docs/auth-profile.md index 5ccb910..84d5306 100644 --- a/backend/docs/auth-profile.md +++ b/backend/docs/auth-profile.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`. diff --git a/backend/docs/backend-architecture.md b/backend/docs/backend-architecture.md new file mode 100644 index 0000000..c8a0756 --- /dev/null +++ b/backend/docs/backend-architecture.md @@ -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 `.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 `.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/.ts +src/api/controllers/.controller.ts +src/services/.ts (+ mappers/validators when needed) +src/db/api/.ts (repository) +src/db/models/.ts +src/shared/constants/.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/.ts` → `export default createCrudService(EntityDBApi, { notFoundCode });` + - `src/api/controllers/.controller.ts` → `export default createCrudController(service, { csvFields });` + - `src/routes/.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). diff --git a/backend/docs/campus-attendance.md b/backend/docs/campus-attendance.md index 16f098f..6baaf4e 100644 --- a/backend/docs/campus-attendance.md +++ b/backend/docs/campus-attendance.md @@ -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=` -- `PUT /api/campus_attendance/configs/:campusKey` -- `GET /api/campus_attendance/summaries` -- `GET /api/campus_attendance/summaries?campusKey=&startDate=&endDate=` -- `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`. diff --git a/backend/docs/campus-catalog.md b/backend/docs/campus-catalog.md index 6841b1d..8797193 100644 --- a/backend/docs/campus-catalog.md +++ b/backend/docs/campus-catalog.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. diff --git a/backend/docs/campuses.md b/backend/docs/campuses.md new file mode 100644 index 0000000..e01bd74 --- /dev/null +++ b/backend/docs/campuses.md @@ -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`. diff --git a/backend/docs/class_enrollments.md b/backend/docs/class_enrollments.md new file mode 100644 index 0000000..b37008c --- /dev/null +++ b/backend/docs/class_enrollments.md @@ -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`. diff --git a/backend/docs/class_subjects.md b/backend/docs/class_subjects.md new file mode 100644 index 0000000..b55ac3c --- /dev/null +++ b/backend/docs/class_subjects.md @@ -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`. diff --git a/backend/docs/classes.md b/backend/docs/classes.md new file mode 100644 index 0000000..a4c3bf1 --- /dev/null +++ b/backend/docs/classes.md @@ -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`. diff --git a/backend/docs/communications.md b/backend/docs/communications.md index c4250ba..e45f0ff 100644 --- a/backend/docs/communications.md +++ b/backend/docs/communications.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=`: 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=`: 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. diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index b2255b2..f25ee03 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -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. diff --git a/backend/docs/cookie-auth.md b/backend/docs/cookie-auth.md index a5caaf9..29dc720 100644 --- a/backend/docs/cookie-auth.md +++ b/backend/docs/cookie-auth.md @@ -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`. diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md new file mode 100644 index 0000000..8d6a703 --- /dev/null +++ b/backend/docs/database-schema.md @@ -0,0 +1,1232 @@ +# Database Schema + +> Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth. +> Regenerate after schema changes. Last generated: 2026-06-09. + +## Overview + +- **Engine:** PostgreSQL via **Sequelize 6** (models in `backend/src/db/models`, typed data access in `backend/src/db/api`). +- **Models:** 38 tables. +- **Primary keys:** every table has a `uuid` `id` (default `UUIDV4`). +- **Soft delete:** all tables are `paranoid` — rows are flagged with `deletedAt` instead of being physically removed. +- **Timestamps:** `createdAt` / `updatedAt` are managed automatically. +- **Audit:** `createdById` / `updatedById` reference `users` (aliases `createdBy` / `updatedBy`). +- **Multi-tenancy:** tenant-owned tables carry `organizationId` and are scoped to the current user's organization in `db/api` (see `full-integration-refactor-plan.md`, tenant boundary workstream). +- **Import idempotency:** `importHash` (unique) deduplicates seeded/imported rows. + +### Type notes + +Types below are the SQL column types. A few Sequelize types are returned as JS `string` at runtime to preserve precision: `DECIMAL`, `NUMERIC`, `BIGINT`, and `DATEONLY` (date-only). `DATE` maps to a JS `Date`. + +## Domains + +- **Tenancy & Access:** `organizations`, `users`, `roles`, `permissions` +- **Campuses & People:** `campuses`, `students`, `guardians`, `staff` +- **Academics:** `academic_years`, `grades`, `subjects`, `classes`, `class_enrollments`, `class_subjects`, `timetables`, `timetable_periods`, `assessments`, `assessment_results` +- **Attendance:** `attendance_sessions`, `attendance_records`, `campus_attendance_config`, `campus_attendance_summaries`, `staff_attendance_records` +- **Finance / Billing:** `fee_plans`, `invoices`, `payments` +- **Communication:** `messages`, `message_recipients`, `communication_events` +- **Content & Product modules:** `content_catalog`, `documents`, `frame_entries`, `user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `personality_quiz_results` +- **System:** `file`, `auth_refresh_tokens` + +## Relationship graph (foreign keys) + +Each edge is a `belongsTo` (the child holds the FK). `createdBy` / `updatedBy` edges to `users` are omitted for readability. + +```mermaid +erDiagram + organizations ||--o{ academic_years : "organization" + organizations ||--o{ assessment_results : "organization" + assessments ||--o{ assessment_results : "assessment" + students ||--o{ assessment_results : "student" + organizations ||--o{ assessments : "organization" + class_subjects ||--o{ assessments : "class_subject" + organizations ||--o{ attendance_records : "organization" + attendance_sessions ||--o{ attendance_records : "attendance_session" + students ||--o{ attendance_records : "student" + organizations ||--o{ attendance_sessions : "organization" + campuses ||--o{ attendance_sessions : "campus" + classes ||--o{ attendance_sessions : "class" + class_subjects ||--o{ attendance_sessions : "class_subject" + staff ||--o{ attendance_sessions : "taken_by" + users ||--o{ auth_refresh_tokens : "user" + organizations ||--o{ auth_refresh_tokens : "organization" + organizations ||--o{ campus_attendance_config : "organization" + campuses ||--o{ campus_attendance_config : "campus" + organizations ||--o{ campus_attendance_summaries : "organization" + campuses ||--o{ campus_attendance_summaries : "campus" + organizations ||--o{ campuses : "organization" + organizations ||--o{ class_enrollments : "organization" + classes ||--o{ class_enrollments : "class" + students ||--o{ class_enrollments : "student" + organizations ||--o{ class_subjects : "organization" + classes ||--o{ class_subjects : "class" + subjects ||--o{ class_subjects : "subject" + staff ||--o{ class_subjects : "teacher" + organizations ||--o{ classes : "organization" + campuses ||--o{ classes : "campus" + academic_years ||--o{ classes : "academic_year" + grades ||--o{ classes : "grade" + staff ||--o{ classes : "homeroom_teacher" + organizations ||--o{ communication_events : "organization" + campuses ||--o{ communication_events : "campus" + organizations ||--o{ documents : "organization" + campuses ||--o{ documents : "campus" + organizations ||--o{ fee_plans : "organization" + academic_years ||--o{ fee_plans : "academic_year" + grades ||--o{ fee_plans : "grade" + organizations ||--o{ frame_entries : "organization" + campuses ||--o{ frame_entries : "campus" + organizations ||--o{ grades : "organization" + organizations ||--o{ guardians : "organization" + students ||--o{ guardians : "student" + organizations ||--o{ invoices : "organization" + campuses ||--o{ invoices : "campus" + students ||--o{ invoices : "student" + fee_plans ||--o{ invoices : "fee_plan" + organizations ||--o{ message_recipients : "organization" + messages ||--o{ message_recipients : "message" + organizations ||--o{ messages : "organization" + campuses ||--o{ messages : "campus" + users ||--o{ messages : "sent_by" + organizations ||--o{ payments : "organization" + invoices ||--o{ payments : "invoice" + staff ||--o{ payments : "received_by" + organizations ||--o{ personality_quiz_results : "organization" + campuses ||--o{ personality_quiz_results : "campus" + users ||--o{ personality_quiz_results : "user" + organizations ||--o{ safety_quiz_results : "organization" + campuses ||--o{ safety_quiz_results : "campus" + users ||--o{ safety_quiz_results : "user" + organizations ||--o{ staff : "organization" + campuses ||--o{ staff : "campus" + users ||--o{ staff : "user" + organizations ||--o{ staff_attendance_records : "organization" + campuses ||--o{ staff_attendance_records : "campus" + users ||--o{ staff_attendance_records : "user" + organizations ||--o{ students : "organization" + campuses ||--o{ students : "campus" + organizations ||--o{ subjects : "organization" + organizations ||--o{ timetable_periods : "organization" + timetables ||--o{ timetable_periods : "timetable" + class_subjects ||--o{ timetable_periods : "class_subject" + organizations ||--o{ timetables : "organization" + campuses ||--o{ timetables : "campus" + academic_years ||--o{ timetables : "academic_year" + organizations ||--o{ user_progress : "organization" + campuses ||--o{ user_progress : "campus" + users ||--o{ user_progress : "user" + roles ||--o{ users : "app_role" + organizations ||--o{ users : "organizations" + organizations ||--o{ walkthrough_checkins : "organization" + campuses ||--o{ walkthrough_checkins : "campus" +``` + +## Table reference + +### Tenancy & Access + +#### `organizations` + +Tenant root. Every tenant-owned row references an organization via `organizationId`. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `users` as `users_organizations` (FK `organizationId`) +- **has many** `campuses` as `campuses_organization` (FK `organizationId`) +- **has many** `academic_years` as `academic_years_organization` (FK `organizationId`) +- **has many** `grades` as `grades_organization` (FK `organizationId`) +- **has many** `subjects` as `subjects_organization` (FK `organizationId`) +- **has many** `students` as `students_organization` (FK `organizationId`) +- **has many** `guardians` as `guardians_organization` (FK `organizationId`) +- **has many** `staff` as `staff_organization` (FK `organizationId`) +- **has many** `classes` as `classes_organization` (FK `organizationId`) +- **has many** `class_enrollments` as `class_enrollments_organization` (FK `organizationId`) +- **has many** `class_subjects` as `class_subjects_organization` (FK `organizationId`) +- **has many** `timetables` as `timetables_organization` (FK `organizationId`) +- **has many** `timetable_periods` as `timetable_periods_organization` (FK `organizationId`) +- **has many** `attendance_sessions` as `attendance_sessions_organization` (FK `organizationId`) +- **has many** `attendance_records` as `attendance_records_organization` (FK `organizationId`) +- **has many** `fee_plans` as `fee_plans_organization` (FK `organizationId`) +- **has many** `invoices` as `invoices_organization` (FK `organizationId`) +- **has many** `payments` as `payments_organization` (FK `organizationId`) +- **has many** `assessments` as `assessments_organization` (FK `organizationId`) +- **has many** `assessment_results` as `assessment_results_organization` (FK `organizationId`) +- **has many** `messages` as `messages_organization` (FK `organizationId`) +- **has many** `message_recipients` as `message_recipients_organization` (FK `organizationId`) +- **has many** `documents` as `documents_organization` (FK `organizationId`) + +#### `users` + +Authentication identities. `email` is required (login + primary contact). Belongs to one organization (`organizationId`, alias `organizations`), one `app_role`, and may hold direct `custom_permissions`. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `firstName` | text | yes | — | | +| `lastName` | text | yes | — | | +| `phoneNumber` | text | yes | — | | +| `email` | text | no | — | | +| `disabled` | boolean | no | false | | +| `password` | text | yes | — | | +| `emailVerified` | boolean | no | false | | +| `emailVerificationToken` | text | yes | — | | +| `emailVerificationTokenExpiresAt` | timestamptz | yes | — | | +| `passwordResetToken` | text | yes | — | | +| `passwordResetTokenExpiresAt` | timestamptz | yes | — | | +| `provider` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `app_roleId` | uuid | yes | — | FK | + +_Relations:_ + +- **many-to-many with** `permissions` as `custom_permissions` (FK `users_custom_permissionsId`) +- **many-to-many with** `permissions` as `custom_permissions_filter` (FK `users_custom_permissionsId`) +- **has many** `staff` as `staff_user` (FK `userId`) +- **has many** `messages` as `messages_sent_by` (FK `sent_byId`) +- **belongs to** `roles` as `app_role` (FK `app_roleId`) +- **belongs to** `organizations` as `organizations` (FK `organizationId`) +- **has many** `file` as `avatar` (FK `belongsToId`) + +#### `roles` + +Named permission sets (RBAC). Linked to permissions M:N; `globalAccess` grants cross-tenant access. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `globalAccess` | boolean | no | false | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **many-to-many with** `permissions` as `permissions` (FK `roles_permissionsId`) +- **many-to-many with** `permissions` as `permissions_filter` (FK `roles_permissionsId`) +- **has many** `users` as `users_app_role` (FK `app_roleId`) + +#### `permissions` + +Flat permission catalog. Permission names follow `${METHOD}_${ENTITY}` (e.g. `READ_CAMPUSES`). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +### Campuses & People + +#### `campuses` + +A physical or online campus belonging to one organization. Parent of students, staff, classes, etc. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `code` | text | yes | — | | +| `address` | text | yes | — | | +| `phone` | text | yes | — | | +| `email` | text | yes | — | | +| `mascot` | text | yes | — | | +| `color` | text | yes | — | | +| `bgGradient` | text | yes | — | | +| `borderColor` | text | yes | — | | +| `textColor` | text | yes | — | | +| `bgLight` | text | yes | — | | +| `description` | text | yes | — | | +| `isOnline` | boolean | no | false | | +| `active` | boolean | no | false | | +| `importHash` | varchar | yes | — | unique, audit | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | + +_Relations:_ + +- **has many** `students` as `students_campus` (FK `campusId`) +- **has many** `staff` as `staff_campus` (FK `campusId`) +- **has many** `classes` as `classes_campus` (FK `campusId`) +- **has many** `timetables` as `timetables_campus` (FK `campusId`) +- **has many** `attendance_sessions` as `attendance_sessions_campus` (FK `campusId`) +- **has many** `invoices` as `invoices_campus` (FK `campusId`) +- **has many** `messages` as `messages_campus` (FK `campusId`) +- **has many** `documents` as `documents_campus` (FK `campusId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + +#### `students` + +Enrolled students. Belong to a campus and organization; have guardians, enrollments, attendance, results, invoices. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `student_number` | text | yes | — | | +| `first_name` | text | yes | — | | +| `last_name` | text | yes | — | | +| `gender` | enum | yes | — | | +| `date_of_birth` | timestamptz | yes | — | | +| `enrollment_date` | timestamptz | yes | — | | +| `status` | enum | yes | — | | +| `email` | text | yes | — | | +| `phone` | text | yes | — | | +| `address` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `guardians` as `guardians_student` (FK `studentId`) +- **has many** `class_enrollments` as `class_enrollments_student` (FK `studentId`) +- **has many** `attendance_records` as `attendance_records_student` (FK `studentId`) +- **has many** `invoices` as `invoices_student` (FK `studentId`) +- **has many** `assessment_results` as `assessment_results_student` (FK `studentId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **has many** `file` as `photo` (FK `belongsToId`) + +#### `guardians` + +Guardians/contacts linked to a student. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `full_name` | text | yes | — | | +| `relationship` | enum | yes | — | | +| `phone` | text | yes | — | | +| `email` | text | yes | — | | +| `address` | text | yes | — | | +| `primary_contact` | boolean | no | false | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `studentId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `students` as `student` (FK `studentId`) + +#### `staff` + +Staff members, optionally linked to a `user` account; can be homeroom teacher, subject teacher, attendance taker, payment receiver. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `employee_number` | text | yes | — | | +| `job_title` | text | yes | — | | +| `staff_type` | enum | yes | — | | +| `hire_date` | timestamptz | yes | — | | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `userId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `classes` as `classes_homeroom_teacher` (FK `homeroom_teacherId`) +- **has many** `class_subjects` as `class_subjects_teacher` (FK `teacherId`) +- **has many** `attendance_sessions` as `attendance_sessions_taken_by` (FK `taken_byId`) +- **has many** `payments` as `payments_received_by` (FK `received_byId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `user` (FK `userId`) +- **has many** `file` as `photo` (FK `belongsToId`) + +### Academics + +#### `academic_years` + +School years (with `current` flag and effective dates) scoping classes, timetables, fee plans. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `start_date` | timestamptz | yes | — | | +| `end_date` | timestamptz | yes | — | | +| `current` | boolean | no | false | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `classes` as `classes_academic_year` (FK `academic_yearId`) +- **has many** `timetables` as `timetables_academic_year` (FK `academic_yearId`) +- **has many** `fee_plans` as `fee_plans_academic_year` (FK `academic_yearId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + +#### `grades` + +Grade levels (with `sort_order`). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `code` | text | yes | — | | +| `sort_order` | integer | yes | — | | +| `description` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `classes` as `classes_grade` (FK `gradeId`) +- **has many** `fee_plans` as `fee_plans_grade` (FK `gradeId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + +#### `subjects` + +Academic subjects (Math, English, …). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `code` | text | yes | — | | +| `description` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `class_subjects` as `class_subjects_subject` (FK `subjectId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + +#### `classes` + +Class/group: belongs to campus, academic year, grade, and a homeroom teacher. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `section` | text | yes | — | | +| `capacity` | integer | yes | — | | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `academic_yearId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `gradeId` | uuid | yes | — | FK | +| `homeroom_teacherId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `class_enrollments` as `class_enrollments_class` (FK `classId`) +- **has many** `class_subjects` as `class_subjects_class` (FK `classId`) +- **has many** `attendance_sessions` as `attendance_sessions_class` (FK `classId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) +- **belongs to** `grades` as `grade` (FK `gradeId`) +- **belongs to** `staff` as `homeroom_teacher` (FK `homeroom_teacherId`) + +#### `class_enrollments` + +Join of student ↔ class with enrolment dates and status. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `enrolled_on` | timestamptz | yes | — | | +| `ended_on` | timestamptz | yes | — | | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `classId` | uuid | yes | — | FK | +| `studentId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `classes` as `class` (FK `classId`) +- **belongs to** `students` as `student` (FK `studentId`) + +#### `class_subjects` + +Join of class ↔ subject ↔ teacher; the unit that schedule, attendance, and assessments hang off. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `classId` | uuid | yes | — | FK | +| `subjectId` | uuid | yes | — | FK | +| `teacherId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `timetable_periods` as `timetable_periods_class_subject` (FK `class_subjectId`) +- **has many** `attendance_sessions` as `attendance_sessions_class_subject` (FK `class_subjectId`) +- **has many** `assessments` as `assessments_class_subject` (FK `class_subjectId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `classes` as `class` (FK `classId`) +- **belongs to** `subjects` as `subject` (FK `subjectId`) +- **belongs to** `staff` as `teacher` (FK `teacherId`) + +#### `timetables` + +A timetable for a campus + academic year, with effective dates. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `effective_from` | timestamptz | yes | — | | +| `effective_to` | timestamptz | yes | — | | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `academic_yearId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `timetable_periods` as `timetable_periods_timetable` (FK `timetableId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) + +#### `timetable_periods` + +A single period (day/time/room) linking a timetable to a class_subject. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `day_of_week` | enum | yes | — | | +| `starts_at` | timestamptz | yes | — | | +| `ends_at` | timestamptz | yes | — | | +| `room` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `class_subjectId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `timetableId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `timetables` as `timetable` (FK `timetableId`) +- **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`) + +#### `assessments` + +Assessments for a class_subject (type, dates, max score, attachments). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `assessment_type` | enum | yes | — | | +| `assigned_at` | timestamptz | yes | — | | +| `due_at` | timestamptz | yes | — | | +| `max_score` | decimal | yes | — | | +| `status` | enum | yes | — | | +| `instructions` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `class_subjectId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `assessment_results` as `assessment_results_assessment` (FK `assessmentId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`) +- **has many** `file` as `attachments` (FK `belongsToId`) + +#### `assessment_results` + +A student's result for an assessment (score, grade_letter). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `score` | decimal | yes | — | | +| `grade_letter` | enum | yes | — | | +| `remarks` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `assessmentId` | uuid | yes | — | FK | +| `studentId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `assessments` as `assessment` (FK `assessmentId`) +- **belongs to** `students` as `student` (FK `studentId`) + +### Attendance + +#### `attendance_sessions` + +An attendance session for a class/class_subject taken by a staff member. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `session_date` | timestamptz | yes | — | | +| `session_type` | enum | yes | — | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `classId` | uuid | yes | — | FK | +| `class_subjectId` | uuid | yes | — | FK | +| `taken_byId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `attendance_records` as `attendance_records_attendance_session` (FK `attendance_sessionId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `classes` as `class` (FK `classId`) +- **belongs to** `class_subjects` as `class_subject` (FK `class_subjectId`) +- **belongs to** `staff` as `taken_by` (FK `taken_byId`) + +#### `attendance_records` + +Per-student attendance within a session (status, minutes_late). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `status` | enum | yes | — | | +| `minutes_late` | integer | yes | — | | +| `remarks` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `attendance_sessionId` | uuid | yes | — | FK | +| `studentId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `attendance_sessions` as `attendance_session` (FK `attendance_sessionId`) +- **belongs to** `students` as `student` (FK `studentId`) + +#### `campus_attendance_config` + +Product-module config for campus attendance. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `campus_key` | text | no | — | | +| `attendance_link` | text | yes | — | | +| `updated_by_label` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + +#### `campus_attendance_summaries` + +Product-module aggregated campus attendance. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `campus_key` | text | no | — | | +| `attendance_date` | date | no | — | | +| `total_enrolled` | integer | no | — | | +| `total_present` | integer | no | — | | +| `total_absent` | integer | no | — | | +| `total_tardy` | integer | no | 0 | | +| `attendance_percentage` | decimal | no | — | | +| `recorded_by_label` | text | yes | — | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + +#### `staff_attendance_records` + +Staff attendance records (present/late/absent). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `attendance_date` | date | no | — | | +| `status` | enum | no | — | | +| `note` | text | yes | — | | +| `user_name` | text | no | — | | +| `user_role` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `userId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `user` (FK `userId`) + +### Finance / Billing + +#### `fee_plans` + +Fee/tuition plans (billing cycle, total amount) for a grade in an academic year. Invoices are generated from these. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `name` | text | yes | — | | +| `billing_cycle` | enum | yes | — | | +| `total_amount` | decimal | yes | — | | +| `active` | boolean | no | false | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `academic_yearId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `gradeId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `invoices` as `invoices_fee_plan` (FK `fee_planId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `academic_years` as `academic_year` (FK `academic_yearId`) +- **belongs to** `grades` as `grade` (FK `gradeId`) + +#### `invoices` + +Invoices issued to a student (amounts, status) — optionally from a fee_plan. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `invoice_number` | text | yes | — | | +| `issue_date` | timestamptz | yes | — | | +| `due_date` | timestamptz | yes | — | | +| `subtotal` | decimal | yes | — | | +| `discount_amount` | decimal | yes | — | | +| `tax_amount` | decimal | yes | — | | +| `total_amount` | decimal | yes | — | | +| `balance_due` | decimal | yes | — | | +| `status` | enum | yes | — | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `campusId` | uuid | yes | — | FK | +| `fee_planId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `studentId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `payments` as `payments_invoice` (FK `invoiceId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `students` as `student` (FK `studentId`) +- **belongs to** `fee_plans` as `fee_plan` (FK `fee_planId`) +- **has many** `file` as `attachments` (FK `belongsToId`) + +#### `payments` + +Payments against an invoice (amount, method, proof file). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `receipt_number` | text | yes | — | | +| `paid_at` | timestamptz | yes | — | | +| `amount` | decimal | yes | — | | +| `method` | enum | yes | — | | +| `reference_code` | text | yes | — | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `invoiceId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `received_byId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `invoices` as `invoice` (FK `invoiceId`) +- **belongs to** `staff` as `received_by` (FK `received_byId`) +- **has many** `file` as `proof` (FK `belongsToId`) + +### Communication + +#### `messages` + +In-app announcements/messages (subject/body/channel/audience) with attachments and recipients. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `subject` | text | yes | — | | +| `body` | text | yes | — | | +| `channel` | enum | yes | — | | +| `audience` | enum | yes | — | | +| `sent_at` | timestamptz | yes | — | | +| `status` | enum | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `sent_byId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **has many** `message_recipients` as `message_recipients_message` (FK `messageId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `sent_by` (FK `sent_byId`) +- **has many** `file` as `attachments` (FK `belongsToId`) + +#### `message_recipients` + +Per-recipient delivery/read tracking for a message (in-app). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `recipient_type` | enum | yes | — | | +| `recipient_label` | text | yes | — | | +| `destination` | text | yes | — | | +| `delivery_status` | enum | yes | — | | +| `delivered_at` | timestamptz | yes | — | | +| `read_at` | timestamptz | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `messageId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `messages` as `message` (FK `messageId`) + +#### `communication_events` + +Product-module communication events (meetings, drills, events, deadlines). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `title` | text | no | — | | +| `event_date` | date | no | — | | +| `event_type` | text | no | — | | +| `roles` | jsonb | no | Array | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + +### Content & Product modules + +#### `content_catalog` + +Product content catalog. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `content_type` | text | no | — | unique | +| `payload` | jsonb | no | — | | +| `active` | boolean | no | true | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | + +#### `documents` + +Polymorphic document records with a `file` attachment, scoped to organization/campus. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `entity_type` | enum | yes | — | | +| `entity_reference` | text | yes | — | | +| `name` | text | yes | — | | +| `category` | enum | yes | — | | +| `uploaded_at` | timestamptz | yes | — | | +| `notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `campusId` | uuid | yes | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **has many** `file` as `file` (FK `belongsToId`) + +#### `frame_entries` + +Product-module "frame" entries. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `week_of` | text | no | — | | +| `posted_date` | text | no | — | | +| `formal` | text | no | — | | +| `recognition` | text | no | — | | +| `application` | text | no | — | | +| `management` | text | no | — | | +| `emotional` | text | no | — | | +| `author` | text | no | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + +#### `user_progress` + +Per-user progress (sign learning, zone check-ins). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `progress_type` | text | no | — | | +| `item_id` | text | no | — | | +| `value` | text | yes | — | | +| `score` | integer | yes | — | | +| `metadata` | jsonb | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `userId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `user` (FK `userId`) + +#### `safety_quiz_results` + +Product-module safety quiz results. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `quiz_id` | text | no | — | | +| `quiz_title` | text | no | — | | +| `week_of` | text | no | — | | +| `score` | integer | no | — | | +| `total_questions` | integer | no | — | | +| `answers` | jsonb | no | — | | +| `user_name` | text | no | — | | +| `user_role` | text | no | — | | +| `completed_at` | timestamptz | no | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `userId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `user` (FK `userId`) + +#### `walkthrough_checkins` + +Product-module walkthrough check-ins. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `teacher_name` | text | no | — | | +| `classroom` | text | no | — | | +| `director_name` | text | no | — | | +| `check_in_date` | date | no | — | | +| `check_in_time` | time | no | — | | +| `attitude_rating` | integer | no | — | | +| `attitude_comment` | text | yes | — | | +| `classroom_management_rating` | integer | no | — | | +| `classroom_management_comment` | text | yes | — | | +| `cleanliness_rating` | integer | no | — | | +| `cleanliness_comment` | text | yes | — | | +| `vibes_rating` | integer | no | — | | +| `vibes_comment` | text | yes | — | | +| `team_dynamics_rating` | integer | no | — | | +| `team_dynamics_comment` | text | yes | — | | +| `emergency_exit_rating` | integer | no | — | | +| `emergency_exit_comment` | text | yes | — | | +| `lesson_plan_rating` | integer | no | — | | +| `lesson_plan_comment` | text | yes | — | | +| `overall_notes` | text | yes | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) + +#### `personality_quiz_results` + +Product-module personality quiz results. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `personality_type` | text | no | — | | +| `quiz_answers` | jsonb | no | — | | +| `completed_at` | timestamptz | no | — | | +| `importHash` | varchar | yes | — | unique, audit | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `organizationId` | uuid | yes | — | FK | +| `campusId` | uuid | yes | — | FK | +| `userId` | uuid | yes | — | FK | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +_Relations:_ + +- **belongs to** `organizations` as `organization` (FK `organizationId`) +- **belongs to** `campuses` as `campus` (FK `campusId`) +- **belongs to** `users` as `user` (FK `userId`) + +### System + +#### `file` + +Polymorphic file/attachment store (`belongsTo` + `belongsToColumn` + `belongsToId`). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `belongsTo` | varchar | yes | — | | +| `belongsToId` | uuid | yes | — | FK | +| `belongsToColumn` | varchar | yes | — | | +| `name` | varchar | no | — | | +| `sizeInBytes` | integer | yes | — | | +| `privateUrl` | varchar | yes | — | | +| `publicUrl` | varchar | no | — | | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | +| `deletedAt` | timestamptz | yes | — | audit | +| `createdById` | uuid | yes | — | FK, audit | +| `updatedById` | uuid | yes | — | FK, audit | + +#### `auth_refresh_tokens` + +Refresh-token rotation records for cookie-based auth (hash, family, expiry, revocation). + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | uuid | no | UUIDV4 | PK | +| `userId` | uuid | no | — | FK | +| `organizationId` | uuid | yes | — | FK | +| `tokenHash` | text | no | — | unique | +| `familyId` | uuid | no | — | FK | +| `previousTokenId` | uuid | yes | — | FK | +| `userAgent` | text | yes | — | | +| `ipAddress` | text | yes | — | | +| `expiresAt` | timestamptz | no | — | | +| `revokedAt` | timestamptz | yes | — | | +| `replacedByTokenId` | uuid | yes | — | FK | +| `createdAt` | timestamptz | yes | — | audit | +| `updatedAt` | timestamptz | yes | — | audit | + +_Relations:_ + +- **belongs to** `users` as `user` (FK `userId`) +- **belongs to** `organizations` as `organization` (FK `organizationId`) + diff --git a/backend/docs/documents.md b/backend/docs/documents.md new file mode 100644 index 0000000..bf7c8a8 --- /dev/null +++ b/backend/docs/documents.md @@ -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: }`. +- `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`. diff --git a/backend/docs/email.md b/backend/docs/email.md new file mode 100644 index 0000000..9f817e6 --- /dev/null +++ b/backend/docs/email.md @@ -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 }`. +- `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` 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 `), `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`). diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md new file mode 100644 index 0000000..949cce1 --- /dev/null +++ b/backend/docs/error-handling.md @@ -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`. diff --git a/backend/docs/fee_plans.md b/backend/docs/fee_plans.md new file mode 100644 index 0000000..c78bc88 --- /dev/null +++ b/backend/docs/fee_plans.md @@ -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`. diff --git a/backend/docs/file.md b/backend/docs/file.md new file mode 100644 index 0000000..c8080bb --- /dev/null +++ b/backend/docs/file.md @@ -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=` -> 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`. diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md index 79b2385..e01b816 100644 --- a/backend/docs/frame-entries.md +++ b/backend/docs/frame-entries.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: }`. ## 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). diff --git a/backend/docs/grades.md b/backend/docs/grades.md new file mode 100644 index 0000000..56c4653 --- /dev/null +++ b/backend/docs/grades.md @@ -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`. diff --git a/backend/docs/guardians.md b/backend/docs/guardians.md new file mode 100644 index 0000000..d2d8671 --- /dev/null +++ b/backend/docs/guardians.md @@ -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`. diff --git a/backend/docs/index.md b/backend/docs/index.md new file mode 100644 index 0000000..4833e1f --- /dev/null +++ b/backend/docs/index.md @@ -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. diff --git a/backend/docs/invoices.md b/backend/docs/invoices.md new file mode 100644 index 0000000..8b2232e --- /dev/null +++ b/backend/docs/invoices.md @@ -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`. diff --git a/backend/docs/message_recipients.md b/backend/docs/message_recipients.md new file mode 100644 index 0000000..384293c --- /dev/null +++ b/backend/docs/message_recipients.md @@ -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`. diff --git a/backend/docs/messages.md b/backend/docs/messages.md new file mode 100644 index 0000000..b7f8fb2 --- /dev/null +++ b/backend/docs/messages.md @@ -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`. diff --git a/backend/docs/migrations-and-seeders.md b/backend/docs/migrations-and-seeders.md new file mode 100644 index 0000000..5db93c4 --- /dev/null +++ b/backend/docs/migrations-and-seeders.md @@ -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/-.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). diff --git a/backend/docs/organizations.md b/backend/docs/organizations.md new file mode 100644 index 0000000..1a05305 --- /dev/null +++ b/backend/docs/organizations.md @@ -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`). diff --git a/backend/docs/payments.md b/backend/docs/payments.md new file mode 100644 index 0000000..0b999f9 --- /dev/null +++ b/backend/docs/payments.md @@ -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`. diff --git a/backend/docs/permissions.md b/backend/docs/permissions.md new file mode 100644 index 0000000..8959eed --- /dev/null +++ b/backend/docs/permissions.md @@ -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: }`. +- `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). diff --git a/backend/docs/personality-quiz-results.md b/backend/docs/personality-quiz-results.md index 5f6f912..de96bcf 100644 --- a/backend/docs/personality-quiz-results.md +++ b/backend/docs/personality-quiz-results.md @@ -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=`: 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). diff --git a/backend/docs/roles.md b/backend/docs/roles.md new file mode 100644 index 0000000..334ba15 --- /dev/null +++ b/backend/docs/roles.md @@ -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). diff --git a/backend/docs/safety-quiz-results.md b/backend/docs/safety-quiz-results.md index b571f6f..f1ed130 100644 --- a/backend/docs/safety-quiz-results.md +++ b/backend/docs/safety-quiz-results.md @@ -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=`: 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: }`. + 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`. diff --git a/backend/docs/search.md b/backend/docs/search.md new file mode 100644 index 0000000..bd51360 --- /dev/null +++ b/backend/docs/search.md @@ -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[]` 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_
')`: the user must have a matching + custom permission or an app-role permission named `READ_
` (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 = ` 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_
` 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`. diff --git a/backend/docs/shared-crud-factories.md b/backend/docs/shared-crud-factories.md new file mode 100644 index 0000000..d4be5b8 --- /dev/null +++ b/backend/docs/shared-crud-factories.md @@ -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` 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(fileBuffer)` (`src/services/shared/csv-import.ts`) — parses an + uploaded CSV buffer into typed rows via a `PassThrough` stream piped through + `csv-parser`. +- `withTransaction(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. diff --git a/backend/docs/staff-attendance.md b/backend/docs/staff-attendance.md index 67fe424..37c7549 100644 --- a/backend/docs/staff-attendance.md +++ b/backend/docs/staff-attendance.md @@ -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=&endDate=&limit=` -- `GET /api/staff_attendance/summary` -- `GET /api/staff_attendance/summary?startDate=&endDate=&limit=` +- `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). diff --git a/backend/docs/staff.md b/backend/docs/staff.md new file mode 100644 index 0000000..4cc586c --- /dev/null +++ b/backend/docs/staff.md @@ -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`. diff --git a/backend/docs/students.md b/backend/docs/students.md new file mode 100644 index 0000000..7b28668 --- /dev/null +++ b/backend/docs/students.md @@ -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`. diff --git a/backend/docs/subjects.md b/backend/docs/subjects.md new file mode 100644 index 0000000..1d1975d --- /dev/null +++ b/backend/docs/subjects.md @@ -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`. diff --git a/backend/docs/timetable_periods.md b/backend/docs/timetable_periods.md new file mode 100644 index 0000000..3e3fcc6 --- /dev/null +++ b/backend/docs/timetable_periods.md @@ -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`. diff --git a/backend/docs/timetables.md b/backend/docs/timetables.md new file mode 100644 index 0000000..c69d3ca --- /dev/null +++ b/backend/docs/timetables.md @@ -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`. diff --git a/backend/docs/typescript-esm-migration-plan.md b/backend/docs/typescript-esm-migration-plan.md deleted file mode 100644 index 3c02a0b..0000000 --- a/backend/docs/typescript-esm-migration-plan.md +++ /dev/null @@ -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>`. -- `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` (применяются только новые). diff --git a/backend/docs/user-progress.md b/backend/docs/user-progress.md index a246f9e..2a31e9f 100644 --- a/backend/docs/user-progress.md +++ b/backend/docs/user-progress.md @@ -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=`: returns current user's progress rows for the requested type. -- `GET /api/user_progress?progress_type=&item_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=&item_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: }`. + 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`. diff --git a/backend/docs/users.md b/backend/docs/users.md new file mode 100644 index 0000000..b3bdb4c --- /dev/null +++ b/backend/docs/users.md @@ -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: }`. 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`). diff --git a/backend/docs/walkthrough-checkins.md b/backend/docs/walkthrough-checkins.md index b7f9945..edb7f1e 100644 --- a/backend/docs/walkthrough-checkins.md +++ b/backend/docs/walkthrough-checkins.md @@ -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=`: 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: }`. + 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`. diff --git a/backend/eslint.config.js b/backend/eslint.config.js deleted file mode 100644 index c166cdb..0000000 --- a/backend/eslint.config.js +++ /dev/null @@ -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', - }, - }, -]; diff --git a/backend/eslint.config.ts b/backend/eslint.config.ts new file mode 100644 index 0000000..35e4653 --- /dev/null +++ b/backend/eslint.config.ts @@ -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.', + }, + ], + }, + ], + }, + }, +]; diff --git a/backend/package-lock.json b/backend/package-lock.json index ade2991..18e309f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,20 +7,16 @@ "name": "schoolchainmanager", "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", @@ -29,22 +25,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" }, "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" }, "engines": { - "node": ">=18" + "node": ">=24" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -95,238 +107,6 @@ "openapi-types": ">=7" } }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.2.tgz", - "integrity": "sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.4.0", - "@azure/core-rest-pipeline": "^1.9.1", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.6.1", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz", - "integrity": "sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-client": "^1.3.0", - "@azure/core-rest-pipeline": "^1.3.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", - "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", - "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", - "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^5.1.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/keyvault-keys": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.8.0.tgz", - "integrity": "sha512-jkuYxgkw0aaRfk40OQhFqDIupqblIOIlYESWB6DKCVDxQet1pyv86Tfk9M+5uFM0+mCs6+MUHU+Hxh3joiUn4Q==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.5.0", - "@azure/core-http-compat": "^2.0.1", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.1.1", - "@azure/core-rest-pipeline": "^1.8.1", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.0.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/keyvault-keys/node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "5.12.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.12.0.tgz", - "integrity": "sha512-eNf2aqx1C6I0yT1GEu5ukblFrmaBXGfe1bivpmlfqvK7giPZvoXLa404C8EfeHVsy6EIryfQuPRzuW1fPxWlHg==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.7.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", - "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.3.tgz", - "integrity": "sha512-YYX4TchEVddVBiybKvKhV9QO/q22jgewP+BVxKG7Uh115voPcviGlypbKERDsqQdAiSTJrwi80gcWFjYKdo8+Q==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.7.0", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -368,6 +148,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -565,6 +787,16 @@ "node": ">=10.0.0" } }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -631,115 +863,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@js-joda/core": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", - "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", - "license": "BSD-3-Clause" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -759,18 +882,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodable/entities": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", @@ -783,12 +894,43 @@ ], "license": "MIT" }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } }, "node_modules/@package-json/types": { "version": "0.0.12", @@ -797,24 +939,139 @@ "dev": true, "license": "MIT" }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "node_modules/@rushstack/node-core-library": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", + "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", "license": "MIT", "dependencies": { - "@noble/hashes": "^1.1.5" + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "z-schema": "~5.0.2" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "license": "MIT", - "optional": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=14" + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", + "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "4.0.2", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", + "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.10.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" } }, "node_modules/@tootallnate/once": { @@ -837,12 +1094,59 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -866,36 +1170,136 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "20.14.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", - "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "@types/express": "*" } }, - "node_modules/@types/readable-stream": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", - "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/request": { "version": "2.48.13", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", @@ -925,6 +1329,45 @@ "node": ">= 0.12" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -932,15 +1375,161 @@ "license": "MIT" }, "node_modules/@types/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==", + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -951,53 +1540,74 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", - "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, "license": "MIT", "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typespec/ts-http-runtime/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { @@ -1313,16 +1923,6 @@ "win32" ] }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1400,32 +2000,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1452,6 +2026,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", @@ -1461,12 +2045,6 @@ "node": ">=8" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" - }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -1482,37 +2060,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1587,41 +2134,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", - "license": "MIT", - "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1736,30 +2248,6 @@ "node": ">=8" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1772,21 +2260,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1857,38 +2330,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1935,17 +2376,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -2051,6 +2481,15 @@ } } }, + "node_modules/deeks": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/deeks/-/deeks-3.2.1.tgz", + "integrity": "sha512-D/o0k3pCG1aI1cxb/dDiWmtMc4Rh7ZQBybXpfMsw9Rbtqwg8kUA9SpYkWcw0pAUjZSnPm8MluctiS0o68r69jQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2058,46 +2497,6 @@ "dev": true, "license": "MIT" }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2107,15 +2506,6 @@ "node": ">=0.4.0" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2125,14 +2515,26 @@ "node": ">= 0.8" } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "license": "ISC", + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doc-path": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/doc-path/-/doc-path-4.1.4.tgz", + "integrity": "sha512-yw5D++UCIB6a033PvQaUvSpW2QuKW0+DOId763n0Q4z3brxS7G8oQr8yBQ1nQFkognKrAVrV6I55TLeU9cfXTg==", + "license": "MIT", + "engines": { + "node": ">=16" } }, "node_modules/doctrine": { @@ -2180,13 +2582,6 @@ "stream-shift": "^1.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2196,80 +2591,23 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/editorconfig": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "^9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } }, "node_modules/encodeurl": { "version": "2.0.0", @@ -2334,14 +2672,46 @@ "node": ">= 0.4" } }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-html": { @@ -2619,15 +2989,6 @@ "node": ">=6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -2795,6 +3156,36 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2863,6 +3254,16 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2948,26 +3349,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2984,39 +3365,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3035,22 +3383,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3112,6 +3444,20 @@ "node": ">= 14" } }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gcp-metadata": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", @@ -3126,25 +3472,6 @@ "node": ">=14" } }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3195,27 +3522,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3229,37 +3535,25 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/google-auth-library": { @@ -3304,7 +3598,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gtoken": { @@ -3320,6 +3613,15 @@ "node": ">=14.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3450,26 +3752,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3487,6 +3769,15 @@ "dev": true, "license": "ISC" }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3512,13 +3803,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3545,7 +3829,6 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -3557,21 +3840,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3582,16 +3850,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3605,24 +3863,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3639,12 +3879,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3657,76 +3891,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" - }, "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "license": "MIT" }, "node_modules/js-yaml": { @@ -3751,6 +3935,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-2-csv": { + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/json-2-csv/-/json-2-csv-5.5.11.tgz", + "integrity": "sha512-kVuwgVL7rfad9ETf02ZZxJPuMR5ZSUn139+T34BfmVxYhb/IsAIm/LzEeQ8YLJmXfuQ5z7LUAFrgPZZM6VLJPw==", + "license": "MIT", + "dependencies": { + "deeks": "3.2.1", + "doc-path": "4.1.4" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3780,55 +3977,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json2csv": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.7.tgz", - "integrity": "sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==", - "license": "MIT", - "dependencies": { - "commander": "^6.1.0", - "jsonparse": "^1.3.1", - "lodash.get": "^4.4.2" - }, - "bin": { - "json2csv": "bin/json2csv.js" - }, - "engines": { - "node": ">= 10", - "npm": ">= 6.13.0" - } - }, - "node_modules/json2csv/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -3936,6 +4084,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -3972,34 +4127,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4018,6 +4145,30 @@ "node": ">= 0.6" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4109,38 +4260,18 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mysql2": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", - "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.2", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.2", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.3.3" - }, "engines": { - "node": ">= 8.0" + "node": ">=16.0.0" }, - "peerDependencies": { - "@types/node": ">= 8" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" } }, "node_modules/napi-postinstall": { @@ -4159,12 +4290,6 @@ "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/native-duplexpair": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", - "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4301,22 +4426,6 @@ "node": ">=8.10.0" } }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4375,24 +4484,6 @@ "wrappy": "1" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -4578,26 +4669,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -4608,6 +4681,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -4714,13 +4797,6 @@ "split2": "^4.1.0" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -4734,6 +4810,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -4783,22 +4881,6 @@ "node": ">= 0.8.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4812,15 +4894,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -4853,6 +4926,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4904,16 +5008,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4927,7 +5021,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", @@ -4980,6 +5073,17 @@ "node": ">=14" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4996,16 +5100,28 @@ "node": ">= 18" } }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "queue-microtask": "^1.2.2" } }, "node_modules/safe-buffer": { @@ -5159,41 +5275,6 @@ } } }, - "node_modules/sequelize-cli": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-6.6.5.tgz", - "integrity": "sha512-DqyISCULOaEbTM+rRQH4YvcUWeOC1XDiSKcjsC6TfAnT7W837mNkChJhtB/Z4FdCFHRCojmiP7zsrA4pARmacA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fs-extra": "^9.1.0", - "js-beautify": "1.15.4", - "lodash": "^4.17.21", - "picocolors": "^1.1.1", - "resolve": "^1.22.1", - "umzug": "^2.3.0", - "yargs": "^16.2.0" - }, - "bin": { - "sequelize": "lib/sequelize", - "sequelize-cli": "lib/sequelize" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/sequelize-json-schema": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/sequelize-json-schema/-/sequelize-json-schema-2.1.1.tgz", - "integrity": "sha512-yCGaHnmQQeL6MQ/fOxhkR5C2aOGZyTD6OrgjP4yw1rbuujuIUVdzWN3AsC6r6AvlGZ3EUBBbCJHKl8OIFFES4Q==", - "license": "MIT", - "engines": { - "node": ">= 8" - }, - "peerDependencies": { - "sequelize": ">= 4" - } - }, "node_modules/sequelize-pool": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", @@ -5203,6 +5284,16 @@ "node": ">= 10.0.0" } }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -5346,6 +5437,16 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -5356,32 +5457,11 @@ } }, "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, - "node_modules/sql-escaper": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", - "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=2.0.0", - "node": ">=12.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" - } - }, - "node_modules/sqlite": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", - "integrity": "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==", - "license": "MIT" - }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -5433,62 +5513,13 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "node": ">=0.6.19" } }, "node_modules/strnum": { @@ -5536,7 +5567,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5658,27 +5688,6 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, - "node_modules/tedious": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-19.2.1.tgz", - "integrity": "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==", - "license": "MIT", - "dependencies": { - "@azure/core-auth": "^1.7.2", - "@azure/identity": "^4.2.1", - "@azure/keyvault-keys": "^4.4.0", - "@js-joda/core": "^5.6.5", - "@types/node": ">=18", - "bl": "^6.1.4", - "iconv-lite": "^0.7.0", - "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">=18.17" - } - }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -5695,6 +5704,65 @@ "node": ">=14" } }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5739,11 +5807,128 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsc-alias": { + "version": "1.8.17", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.17.tgz", + "integrity": "sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/tsc-alias/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsc-alias/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } }, "node_modules/type-check": { "version": "0.4.0", @@ -5758,6 +5943,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5777,6 +5974,44 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", @@ -5784,16 +6019,19 @@ "license": "MIT" }, "node_modules/umzug": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", - "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", - "dev": true, + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.3.tgz", + "integrity": "sha512-U9SRJI6LJvV0XwrqGMVPBkE26WHJklHZjtscJ2sEjUp7f+h4NH/25YGjPBernWLroVJvMnTkCAGC0bT0dd63qA==", "license": "MIT", "dependencies": { - "bluebird": "^3.7.2" + "@rushstack/ts-command-line": "4.19.1", + "emittery": "^0.13.0", + "pony-cause": "^2.1.4", + "tinyglobby": "^0.2.16", + "type-fest": "^4.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12" } }, "node_modules/undefsafe": { @@ -5810,21 +6048,11 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5898,16 +6126,16 @@ } }, "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validator": { @@ -5978,64 +6206,12 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -6060,15 +6236,11 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/yaml": { "version": "2.0.0-1", @@ -6079,35 +6251,6 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6119,6 +6262,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/backend/package.json b/backend/package.json index 342c75f..7216e3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/scripts/copy-assets.mjs b/backend/scripts/copy-assets.mjs new file mode 100644 index 0000000..6c65fa4 --- /dev/null +++ b/backend/scripts/copy-assets.mjs @@ -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}`); diff --git a/backend/src/ai/LocalAIApi.js b/backend/src/ai/LocalAIApi.js deleted file mode 100644 index fd571ae..0000000 --- a/backend/src/ai/LocalAIApi.js +++ /dev/null @@ -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, -}; diff --git a/backend/src/api/controllers/academic_years.controller.ts b/backend/src/api/controllers/academic_years.controller.ts new file mode 100644 index 0000000..87e0fa7 --- /dev/null +++ b/backend/src/api/controllers/academic_years.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/assessment_results.controller.ts b/backend/src/api/controllers/assessment_results.controller.ts new file mode 100644 index 0000000..52eb9e0 --- /dev/null +++ b/backend/src/api/controllers/assessment_results.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/assessments.controller.ts b/backend/src/api/controllers/assessments.controller.ts new file mode 100644 index 0000000..4572921 --- /dev/null +++ b/backend/src/api/controllers/assessments.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/attendance_records.controller.ts b/backend/src/api/controllers/attendance_records.controller.ts new file mode 100644 index 0000000..ba99a13 --- /dev/null +++ b/backend/src/api/controllers/attendance_records.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/attendance_sessions.controller.ts b/backend/src/api/controllers/attendance_sessions.controller.ts new file mode 100644 index 0000000..33d5f9c --- /dev/null +++ b/backend/src/api/controllers/attendance_sessions.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/auth.controller.ts b/backend/src/api/controllers/auth.controller.ts new file mode 100644 index 0000000..35bc7a7 --- /dev/null +++ b/backend/src/api/controllers/auth.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await AuthService.revokeSession(cookies.extractRefreshCookie(req) ?? undefined); + cookies.clearSessionCookies(res); + res.status(204).send(); +} + +export async function me(req: Request, res: Response): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await socialRedirect(req, res, req.user); +} diff --git a/backend/src/api/controllers/campus_attendance.controller.ts b/backend/src/api/controllers/campus_attendance.controller.ts new file mode 100644 index 0000000..e088089 --- /dev/null +++ b/backend/src/api/controllers/campus_attendance.controller.ts @@ -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 { + const payload = await CampusAttendanceService.listConfigs( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function upsertConfig(req: Request, res: Response): Promise { + 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 { + const payload = await CampusAttendanceService.listSummaries( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function upsertSummary(req: Request, res: Response): Promise { + const payload = await CampusAttendanceService.upsertSummary( + req.params.campusKey, + req.params.date, + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/campuses.controller.ts b/backend/src/api/controllers/campuses.controller.ts new file mode 100644 index 0000000..9c6f4e7 --- /dev/null +++ b/backend/src/api/controllers/campuses.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/class_enrollments.controller.ts b/backend/src/api/controllers/class_enrollments.controller.ts new file mode 100644 index 0000000..b7bc487 --- /dev/null +++ b/backend/src/api/controllers/class_enrollments.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/class_subjects.controller.ts b/backend/src/api/controllers/class_subjects.controller.ts new file mode 100644 index 0000000..36fa95c --- /dev/null +++ b/backend/src/api/controllers/class_subjects.controller.ts @@ -0,0 +1,4 @@ +import service from '@/services/class_subjects'; +import { createCrudController } from '@/api/controllers/shared/crud-controller'; + +export default createCrudController(service, { csvFields: ['id'] }); diff --git a/backend/src/api/controllers/classes.controller.ts b/backend/src/api/controllers/classes.controller.ts new file mode 100644 index 0000000..a407a04 --- /dev/null +++ b/backend/src/api/controllers/classes.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/communications.controller.ts b/backend/src/api/controllers/communications.controller.ts new file mode 100644 index 0000000..e210816 --- /dev/null +++ b/backend/src/api/controllers/communications.controller.ts @@ -0,0 +1,40 @@ +import type { Request, Response } from 'express'; +import CommunicationsService from '@/services/communications'; + +export async function listParentMessages( + req: Request, + res: Response, +): Promise { + const payload = await CommunicationsService.listParentMessages( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function createParentMessage( + req: Request, + res: Response, +): Promise { + const payload = await CommunicationsService.createParentMessage( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} + +export async function listEvents(req: Request, res: Response): Promise { + const payload = await CommunicationsService.listEvents( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function createEvent(req: Request, res: Response): Promise { + const payload = await CommunicationsService.createEvent( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} diff --git a/backend/src/api/controllers/content_catalog.controller.ts b/backend/src/api/controllers/content_catalog.controller.ts new file mode 100644 index 0000000..f87826c --- /dev/null +++ b/backend/src/api/controllers/content_catalog.controller.ts @@ -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 { + const payload = await ContentCatalogService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await ContentCatalogService.create( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} + +export async function findManagedByType( + req: Request, + res: Response, +): Promise { + const payload = await ContentCatalogService.findManagedByType( + req.params.contentType, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function update(req: Request, res: Response): Promise { + 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 { + await ContentCatalogService.delete(req.params.contentType, req.currentUser); + res.status(204).send(); +} diff --git a/backend/src/api/controllers/documents.controller.ts b/backend/src/api/controllers/documents.controller.ts new file mode 100644 index 0000000..d8d0545 --- /dev/null +++ b/backend/src/api/controllers/documents.controller.ts @@ -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 { + const document = await Service.create(req.body.data, req.currentUser); + res.status(201).send(document); +} + +export async function bulkImport(req: Request, res: Response): Promise { + 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 { + 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 { + await Service.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(true); +} + +export async function deleteByIds(req: Request, res: Response): Promise { + await Service.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +} + +export async function list(req: Request, res: Response): Promise { + 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 { + 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 { + 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 { + const payload = await Service.findById(paramStr(req.params.id)); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/fee_plans.controller.ts b/backend/src/api/controllers/fee_plans.controller.ts new file mode 100644 index 0000000..a3e34a3 --- /dev/null +++ b/backend/src/api/controllers/fee_plans.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/file.controller.ts b/backend/src/api/controllers/file.controller.ts new file mode 100644 index 0000000..9260ee2 --- /dev/null +++ b/backend/src/api/controllers/file.controller.ts @@ -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); + } +} diff --git a/backend/src/api/controllers/frame_entries.controller.ts b/backend/src/api/controllers/frame_entries.controller.ts new file mode 100644 index 0000000..070d762 --- /dev/null +++ b/backend/src/api/controllers/frame_entries.controller.ts @@ -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 { + const payload = await FrameEntriesService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await FrameEntriesService.create( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} + +export async function update(req: Request, res: Response): Promise { + const payload = await FrameEntriesService.update( + paramStr(req.params.id), + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/grades.controller.ts b/backend/src/api/controllers/grades.controller.ts new file mode 100644 index 0000000..15c3f29 --- /dev/null +++ b/backend/src/api/controllers/grades.controller.ts @@ -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; diff --git a/backend/src/api/controllers/guardians.controller.ts b/backend/src/api/controllers/guardians.controller.ts new file mode 100644 index 0000000..fd8685b --- /dev/null +++ b/backend/src/api/controllers/guardians.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/invoices.controller.ts b/backend/src/api/controllers/invoices.controller.ts new file mode 100644 index 0000000..beb2563 --- /dev/null +++ b/backend/src/api/controllers/invoices.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/message_recipients.controller.ts b/backend/src/api/controllers/message_recipients.controller.ts new file mode 100644 index 0000000..ab28f03 --- /dev/null +++ b/backend/src/api/controllers/message_recipients.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/messages.controller.ts b/backend/src/api/controllers/messages.controller.ts new file mode 100644 index 0000000..13c6c53 --- /dev/null +++ b/backend/src/api/controllers/messages.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/organizations.controller.ts b/backend/src/api/controllers/organizations.controller.ts new file mode 100644 index 0000000..2d61665 --- /dev/null +++ b/backend/src/api/controllers/organizations.controller.ts @@ -0,0 +1,4 @@ +import service from '@/services/organizations'; +import { createCrudController } from '@/api/controllers/shared/crud-controller'; + +export default createCrudController(service, { csvFields: ['id', 'name'] }); diff --git a/backend/src/api/controllers/payments.controller.ts b/backend/src/api/controllers/payments.controller.ts new file mode 100644 index 0000000..62bae20 --- /dev/null +++ b/backend/src/api/controllers/payments.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/permissions.controller.ts b/backend/src/api/controllers/permissions.controller.ts new file mode 100644 index 0000000..33a4895 --- /dev/null +++ b/backend/src/api/controllers/permissions.controller.ts @@ -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 { + await Service.create(req.body.data, req.currentUser); + res.status(200).send(true); +} + +export async function bulkImport(req: Request, res: Response): Promise { + 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 { + 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 { + await Service.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(true); +} + +export async function deleteByIds(req: Request, res: Response): Promise { + await Service.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +} + +export async function list(req: Request, res: Response): Promise { + 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 { + const payload = await Service.count(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function autocomplete(req: Request, res: Response): Promise { + 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 { + const payload = await Service.findById(paramStr(req.params.id)); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/personality_quiz_results.controller.ts b/backend/src/api/controllers/personality_quiz_results.controller.ts new file mode 100644 index 0000000..446982e --- /dev/null +++ b/backend/src/api/controllers/personality_quiz_results.controller.ts @@ -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 { + const payload = await PersonalityQuizResultsService.getCurrentUserResult( + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function upsertCurrentUserResult( + req: Request, + res: Response, +): Promise { + const payload = await PersonalityQuizResultsService.upsertCurrentUserResult( + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function distribution(req: Request, res: Response): Promise { + const payload = await PersonalityQuizResultsService.distribution( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/public_campuses.controller.ts b/backend/src/api/controllers/public_campuses.controller.ts new file mode 100644 index 0000000..9555998 --- /dev/null +++ b/backend/src/api/controllers/public_campuses.controller.ts @@ -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 { + const payload = await CampusCatalogService.listActive(); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/public_content_catalog.controller.ts b/backend/src/api/controllers/public_content_catalog.controller.ts new file mode 100644 index 0000000..3ec78f3 --- /dev/null +++ b/backend/src/api/controllers/public_content_catalog.controller.ts @@ -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 { + const payload = await ContentCatalogService.findByType( + paramStr(req.params.contentType), + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/roles.controller.ts b/backend/src/api/controllers/roles.controller.ts new file mode 100644 index 0000000..652b216 --- /dev/null +++ b/backend/src/api/controllers/roles.controller.ts @@ -0,0 +1,4 @@ +import service from '@/services/roles'; +import { createCrudController } from '@/api/controllers/shared/crud-controller'; + +export default createCrudController(service, { csvFields: ['id', 'name'] }); diff --git a/backend/src/api/controllers/safety_quiz_results.controller.ts b/backend/src/api/controllers/safety_quiz_results.controller.ts new file mode 100644 index 0000000..295ad80 --- /dev/null +++ b/backend/src/api/controllers/safety_quiz_results.controller.ts @@ -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 { + const payload = await SafetyQuizResultsService.list( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await SafetyQuizResultsService.create( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} diff --git a/backend/src/api/controllers/search.controller.ts b/backend/src/api/controllers/search.controller.ts new file mode 100644 index 0000000..17c9932 --- /dev/null +++ b/backend/src/api/controllers/search.controller.ts @@ -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 { + 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' }); + } +} diff --git a/backend/src/api/controllers/shared/crud-controller.ts b/backend/src/api/controllers/shared/crud-controller.ts new file mode 100644 index 0000000..b4416bc --- /dev/null +++ b/backend/src/api/controllers/shared/crud-controller.ts @@ -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 { + create(data: CreateData, currentUser?: CurrentUserArg): Promise; + bulkImport(fileBuffer: Buffer, currentUser?: CurrentUserArg): Promise; + update( + data: UpdateData, + id: string, + currentUser?: CurrentUserArg, + ): Promise; + remove(id: string, currentUser?: CurrentUserArg): Promise; + deleteByIds(ids: string[], currentUser?: CurrentUserArg): Promise; + list( + filter: Request['query'], + globalAccess: boolean, + currentUser?: CurrentUserArg, + ): Promise<{ rows: object[]; count: number }>; + count( + filter: Request['query'], + globalAccess: boolean, + currentUser?: CurrentUserArg, + ): Promise; + autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ): Promise; + findById(id: string): Promise; +} + +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( + service: CrudControllerService, + { csvFields }: { csvFields: string[] }, +) { + return { + async create(req: Request, res: Response): Promise { + await service.create(req.body.data, req.currentUser); + res.status(200).send(true); + }, + + async bulkImport(req: Request, res: Response): Promise { + 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 { + await service.update(req.body.data, req.body.id, req.currentUser); + res.status(200).send(true); + }, + + async remove(req: Request, res: Response): Promise { + await service.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(true); + }, + + async deleteByIds(req: Request, res: Response): Promise { + await service.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); + }, + + async list(req: Request, res: Response): Promise { + 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 { + const payload = await service.count( + req.query, + globalAccessOf(req), + req.currentUser, + ); + res.status(200).send(payload); + }, + + async autocomplete(req: Request, res: Response): Promise { + 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 { + const payload = await service.findById(paramStr(req.params.id)); + res.status(200).send(payload); + }, + }; +} + +export type CrudController = ReturnType; diff --git a/backend/src/api/controllers/staff.controller.ts b/backend/src/api/controllers/staff.controller.ts new file mode 100644 index 0000000..16ebd0e --- /dev/null +++ b/backend/src/api/controllers/staff.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/staff_attendance.controller.ts b/backend/src/api/controllers/staff_attendance.controller.ts new file mode 100644 index 0000000..cddaa4f --- /dev/null +++ b/backend/src/api/controllers/staff_attendance.controller.ts @@ -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 { + const payload = await StaffAttendanceService.listRecords( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function summary(req: Request, res: Response): Promise { + const payload = await StaffAttendanceService.summary( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/students.controller.ts b/backend/src/api/controllers/students.controller.ts new file mode 100644 index 0000000..7eefbff --- /dev/null +++ b/backend/src/api/controllers/students.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/subjects.controller.ts b/backend/src/api/controllers/subjects.controller.ts new file mode 100644 index 0000000..6b2618e --- /dev/null +++ b/backend/src/api/controllers/subjects.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/timetable_periods.controller.ts b/backend/src/api/controllers/timetable_periods.controller.ts new file mode 100644 index 0000000..b535ed0 --- /dev/null +++ b/backend/src/api/controllers/timetable_periods.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/timetables.controller.ts b/backend/src/api/controllers/timetables.controller.ts new file mode 100644 index 0000000..9aa367d --- /dev/null +++ b/backend/src/api/controllers/timetables.controller.ts @@ -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'] }); diff --git a/backend/src/api/controllers/user_progress.controller.ts b/backend/src/api/controllers/user_progress.controller.ts new file mode 100644 index 0000000..5bf9cdd --- /dev/null +++ b/backend/src/api/controllers/user_progress.controller.ts @@ -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 { + const payload = await UserProgressService.list(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function upsert(req: Request, res: Response): Promise { + const payload = await UserProgressService.upsert( + req.body.data, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function removeByItem(req: Request, res: Response): Promise { + const payload = await UserProgressService.removeByItem( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/users.controller.ts b/backend/src/api/controllers/users.controller.ts new file mode 100644 index 0000000..f0c0f0f --- /dev/null +++ b/backend/src/api/controllers/users.controller.ts @@ -0,0 +1,97 @@ +import type { Request, Response } from 'express'; +import { paramStr, queryNum, queryStr } from '@/api/http/request'; +import { toCsv } from '@/shared/csv'; +import Service from '@/services/users'; +import processFile from '@/middlewares/upload'; +import ValidationError from '@/shared/errors/validation'; + +const CSV_FIELDS = ['id', 'firstName', 'lastName', 'phoneNumber', 'email']; + +function globalAccessOf(req: Request): boolean { + return req.currentUser?.app_role?.globalAccess ?? false; +} + +/** The UI host that invitation/reset links should point back to. */ +function hostFromReferer(req: Request): string { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + return new URL(referer).host; +} + +export async function create(req: Request, res: Response): Promise { + await Service.create(req.body.data, req.currentUser, true, hostFromReferer(req)); + res.status(200).send(true); +} + +export async function bulkImport(req: Request, res: Response): Promise { + await processFile(req, res); + + if (!req.file) { + throw new ValidationError('importer.errors.invalidFileEmpty'); + } + + await Service.bulkImport( + req.file.buffer, + req.currentUser, + true, + hostFromReferer(req), + ); + res.status(200).send(true); +} + +export async function update(req: Request, res: Response): Promise { + 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 { + await Service.remove(paramStr(req.params.id), req.currentUser); + res.status(200).send(true); +} + +export async function deleteByIds(req: Request, res: Response): Promise { + await Service.deleteByIds(req.body.data, req.currentUser); + res.status(200).send(true); +} + +export async function list(req: Request, res: Response): Promise { + const payload = await Service.list( + req.query, + globalAccessOf(req), + 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 { + 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 { + 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 { + const payload = await Service.findById(paramStr(req.params.id)); + res.status(200).send(payload); +} diff --git a/backend/src/api/controllers/walkthrough_checkins.controller.ts b/backend/src/api/controllers/walkthrough_checkins.controller.ts new file mode 100644 index 0000000..cc8cf99 --- /dev/null +++ b/backend/src/api/controllers/walkthrough_checkins.controller.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from 'express'; +import { paramStr } from '@/api/http/request'; +import WalkthroughCheckinsService from '@/services/walkthrough_checkins'; + +export async function list(req: Request, res: Response): Promise { + const payload = await WalkthroughCheckinsService.list( + req.query, + req.currentUser, + ); + res.status(200).send(payload); +} + +export async function create(req: Request, res: Response): Promise { + const payload = await WalkthroughCheckinsService.create( + req.body.data, + req.currentUser, + ); + res.status(201).send(payload); +} + +export async function remove(req: Request, res: Response): Promise { + const payload = await WalkthroughCheckinsService.remove( + paramStr(req.params.id), + req.currentUser, + ); + res.status(200).send(payload); +} diff --git a/backend/src/api/http/crud-router.ts b/backend/src/api/http/crud-router.ts new file mode 100644 index 0000000..20da268 --- /dev/null +++ b/backend/src/api/http/crud-router.ts @@ -0,0 +1,29 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import type { CrudController } from '@/api/controllers/shared/crud-controller'; + +/** + * Wires the 9 generic-CRUD routes (guarded by `checkCrudPermissions(permission)`) + * to a controller built by `createCrudController`. + */ +export function createCrudRouter( + controller: CrudController, + { permission }: { permission: string }, +): express.Router { + const router = express.Router(); + + router.use(permissions.checkCrudPermissions(permission)); + + router.post('/', wrapAsync(controller.create)); + router.post('/bulk-import', wrapAsync(controller.bulkImport)); + router.put('/:id', wrapAsync(controller.update)); + router.delete('/:id', wrapAsync(controller.remove)); + router.post('/deleteByIds', wrapAsync(controller.deleteByIds)); + router.get('/', wrapAsync(controller.list)); + router.get('/count', wrapAsync(controller.count)); + router.get('/autocomplete', wrapAsync(controller.autocomplete)); + router.get('/:id', wrapAsync(controller.findById)); + + return router; +} diff --git a/backend/src/api/http/request.ts b/backend/src/api/http/request.ts new file mode 100644 index 0000000..6c9eae5 --- /dev/null +++ b/backend/src/api/http/request.ts @@ -0,0 +1,37 @@ +import type { + Request, + Response, + NextFunction, + RequestHandler, +} from 'express'; + +/** Wraps an async handler so a rejected promise reaches the error middleware. */ +export function wrapAsync( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return function (req, res, next) { + fn(req, res, next).catch(next); + }; +} + +/** Reads a query-string value as a single string, or undefined. */ +export function queryStr(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** Reads a query-string value as a finite number, or undefined. */ +export function queryNum(value: unknown): number | undefined { + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** Reads a route param as a single string (params may be string[]). */ +export function paramStr(value: string | string[] | undefined): string { + if (Array.isArray(value)) { + return value[0] ?? ''; + } + return value ?? ''; +} diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js deleted file mode 100644 index 6c66b06..0000000 --- a/backend/src/auth/auth.js +++ /dev/null @@ -1,61 +0,0 @@ -const config = require('../config'); -const providers = config.providers; -const db = require('../db/models'); -const { extractAccessCookie } = require('./cookies'); - -const passport = require('passport'); -const JWTstrategy = require('passport-jwt').Strategy; -const GoogleStrategy = require('passport-google-oauth2').Strategy; -const MicrosoftStrategy = require('passport-microsoft').Strategy; -const UsersDBApi = require('../db/api/users'); - - -passport.use(new JWTstrategy({ - passReqToCallback: true, - secretOrKey: config.secret_key, - jwtFromRequest: extractAccessCookie -}, async (req, token, done) => { - try { - const user = await UsersDBApi.findBy( {email: token.user.email}); - - if (user && user.disabled) { - return done (new Error(`User '${user.email}' is disabled`)); - } - - req.currentUser = user; - - return done(null, user); - } catch (error) { - done(error); - } -})); - -passport.use(new GoogleStrategy({ - clientID: config.google.clientId, - clientSecret: config.google.clientSecret, - callbackURL: config.apiUrl + '/auth/signin/google/callback', - passReqToCallback: true - }, - function (request, accessToken, refreshToken, profile, done) { - socialStrategy(profile.email, profile, providers.GOOGLE, done); - } -)); - - -passport.use(new MicrosoftStrategy({ - clientID: config.microsoft.clientId, - clientSecret: config.microsoft.clientSecret, - callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', - passReqToCallback: true - }, - function (request, accessToken, refreshToken, profile, done) { - const email = profile._json.mail || profile._json.userPrincipalName; - socialStrategy(email, profile, providers.MICROSOFT, done); - } -)); - -function socialStrategy(email, profile, provider, done) { - db.users.findOrCreate({where: {email, provider}}).then(([user]) => { - return done(null, {user}); - }); -} diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts new file mode 100644 index 0000000..ef35722 --- /dev/null +++ b/backend/src/auth/auth.ts @@ -0,0 +1,87 @@ +import passport from 'passport'; +import { Strategy as JwtStrategy } from 'passport-jwt'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth2'; +import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; +import type { Request } from 'express'; +import config from '@/shared/config'; +import db from '@/db/models'; +import UsersDBApi from '@/db/api/users'; +import cookies from '@/auth/cookies'; + +const providers = config.providers; + +interface JwtPayload { + user: { email: string }; +} + +type VerifyDone = (error: unknown, user?: unknown) => void; + +passport.use( + new JwtStrategy( + { + passReqToCallback: true, + secretOrKey: config.secret_key, + jwtFromRequest: cookies.extractAccessCookie, + }, + async (req: Request, token: JwtPayload, done: VerifyDone) => { + try { + const user = await UsersDBApi.findBy({ email: token.user.email }); + + if (user && user.disabled) { + return done(new Error(`User '${user.email}' is disabled`)); + } + + req.currentUser = user ?? undefined; + + return done(null, user); + } catch (error) { + done(error); + } + }, + ), +); + +function socialStrategy( + email: string, + _profile: unknown, + provider: string, + done: VerifyDone, +): void { + db.users + .findOrCreate({ where: { email, provider } }) + .then(([user]) => done(null, { user })) + .catch((error: unknown) => done(error)); +} + +if (config.google.clientId && config.google.clientSecret) { + passport.use( + new GoogleStrategy( + { + clientID: config.google.clientId, + clientSecret: config.google.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/google/callback', + passReqToCallback: true, + }, + (_request, _accessToken, _refreshToken, profile, done) => { + socialStrategy(profile.email ?? '', profile, providers.GOOGLE, done); + }, + ), + ); +} + +if (config.microsoft.clientId && config.microsoft.clientSecret) { + passport.use( + new MicrosoftStrategy( + { + clientID: config.microsoft.clientId, + clientSecret: config.microsoft.clientSecret, + callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', + passReqToCallback: true, + }, + (_request, _accessToken, _refreshToken, profile, done) => { + const email = profile._json.mail || profile._json.userPrincipalName || ''; + socialStrategy(email, profile, providers.MICROSOFT, done); + }, + ), + ); +} diff --git a/backend/src/auth/cookies.js b/backend/src/auth/cookies.ts similarity index 63% rename from backend/src/auth/cookies.js rename to backend/src/auth/cookies.ts index 42ba6ce..dff4e0c 100644 --- a/backend/src/auth/cookies.js +++ b/backend/src/auth/cookies.ts @@ -1,10 +1,16 @@ -const config = require('../config'); +import type { CookieOptions, Request, Response } from 'express'; +import config from '@/shared/config'; -function encodeCookieValue(value) { +interface SessionTokens { + accessToken: string; + refreshToken: string; +} + +function encodeCookieValue(value: string): string { return encodeURIComponent(value); } -function decodeCookieValue(value) { +function decodeCookieValue(value: string): string { try { return decodeURIComponent(value); } catch { @@ -12,8 +18,8 @@ function decodeCookieValue(value) { } } -function getBaseCookieOptions(maxAge) { - const options = { +function getBaseCookieOptions(maxAge: number): CookieOptions { + const options: CookieOptions = { httpOnly: true, maxAge, path: config.auth.cookiePath, @@ -28,8 +34,8 @@ function getBaseCookieOptions(maxAge) { return options; } -function getClearCookieOptions() { - const options = { +function getClearCookieOptions(): CookieOptions { + const options: CookieOptions = { path: config.auth.cookiePath, }; @@ -40,41 +46,41 @@ function getClearCookieOptions() { return options; } -function getAccessCookieOptions() { +function getAccessCookieOptions(): CookieOptions { return getBaseCookieOptions(config.auth.accessTokenMaxAgeMs); } -function getRefreshCookieOptions() { +function getRefreshCookieOptions(): CookieOptions { return getBaseCookieOptions(config.auth.refreshTokenMaxAgeMs); } -function setAccessCookie(res, token) { +function setAccessCookie(res: Response, token: string): void { res.cookie(config.auth.accessCookieName, token, getAccessCookieOptions()); } -function setRefreshCookie(res, token) { +function setRefreshCookie(res: Response, token: string): void { res.cookie(config.auth.refreshCookieName, token, getRefreshCookieOptions()); } -function setSessionCookies(res, session) { +function setSessionCookies(res: Response, session: SessionTokens): void { setAccessCookie(res, session.accessToken); setRefreshCookie(res, session.refreshToken); } -function clearAccessCookie(res) { +function clearAccessCookie(res: Response): void { res.clearCookie(config.auth.accessCookieName, getClearCookieOptions()); } -function clearRefreshCookie(res) { +function clearRefreshCookie(res: Response): void { res.clearCookie(config.auth.refreshCookieName, getClearCookieOptions()); } -function clearSessionCookies(res) { +function clearSessionCookies(res: Response): void { clearAccessCookie(res); clearRefreshCookie(res); } -function extractCookie(req, cookieName) { +function extractCookie(req: Request, cookieName: string): string | null { const cookieHeader = req.headers.cookie; if (!cookieHeader) { @@ -103,19 +109,19 @@ function extractCookie(req, cookieName) { return null; } -function extractAccessCookie(req) { +function extractAccessCookie(req: Request): string | null { return extractCookie(req, config.auth.accessCookieName); } -function extractRefreshCookie(req) { +function extractRefreshCookie(req: Request): string | null { return extractCookie(req, config.auth.refreshCookieName); } -function serializeAccessCookie(token) { +function serializeAccessCookie(token: string): string { return `${config.auth.accessCookieName}=${encodeCookieValue(token)}`; } -module.exports = { +export default { clearAccessCookie, clearRefreshCookie, clearSessionCookies, diff --git a/backend/src/config.js b/backend/src/config.js deleted file mode 100644 index a817106..0000000 --- a/backend/src/config.js +++ /dev/null @@ -1,208 +0,0 @@ - -const os = require('os'); -require('./config/load-env'); - -const { - AUTH_COOKIE_NAME, - AUTH_COOKIE_PATH, - AUTH_REFRESH_COOKIE_NAME, - AUTH_COOKIE_SAME_SITE_VALUES, - AUTH_PROVIDERS, - BCRYPT_SALT_ROUNDS, - DEFAULT_AUTH_COOKIE_SAME_SITE, - JWT_EXPIRES_IN_MS, - REFRESH_TOKEN_BYTES, - REFRESH_TOKEN_EXPIRES_IN_MS, - REFRESH_TOKEN_HASH_ALGORITHM, -} = require('./constants/auth'); -const { - DEFAULT_DEV_API_PORT, - DEFAULT_DEV_UI_PORT, - DEFAULT_DEV_HOST, - DEFAULT_EMAIL_FROM, - DEFAULT_EMAIL_HOST, - DEFAULT_EMAIL_PORT, - DEFAULT_FLATLOGIC_HOST, - PRODUCTION_FLATLOGIC_HOST, - DEFAULT_PEXELS_QUERY, -} = require('./constants/app'); -const { GENERATED_ROLE_NAMES } = require('./constants/roles'); - -function requiredEnv(name) { - const value = process.env[name]; - - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - - return value; -} - -function readBooleanEnv(name, defaultValue) { - const value = process.env[name]; - - if (value === undefined || value === '') { - return defaultValue; - } - - if (value === 'true') { - return true; - } - - if (value === 'false') { - return false; - } - - throw new Error(`Invalid boolean environment variable: ${name}`); -} - -function readNumberEnv(name, defaultValue) { - const value = process.env[name]; - - if (value === undefined || value === '') { - return defaultValue; - } - - const parsedValue = Number(value); - - if (!Number.isFinite(parsedValue) || parsedValue <= 0) { - throw new Error(`Invalid positive number environment variable: ${name}`); - } - - return parsedValue; -} - -function readListEnv(name, defaultValue) { - const value = process.env[name]; - - if (!value) { - return defaultValue; - } - - return value - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function normalizeSameSite(value) { - const normalizedValue = value.toLowerCase(); - - if (!AUTH_COOKIE_SAME_SITE_VALUES.includes(normalizedValue)) { - throw new Error(`Invalid AUTH_COOKIE_SAME_SITE value: ${value}`); - } - - return normalizedValue; -} - -const isProductionLike = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage'; -const isProduction = process.env.NODE_ENV === 'production'; -const authCookieSameSite = normalizeSameSite(process.env.AUTH_COOKIE_SAME_SITE || DEFAULT_AUTH_COOKIE_SAME_SITE); -const authCookieSecure = readBooleanEnv('AUTH_COOKIE_SECURE', isProductionLike); -const authCookieMaxAgeMs = readNumberEnv('AUTH_COOKIE_MAX_AGE_MS', JWT_EXPIRES_IN_MS); -const defaultApiOrigin = `${DEFAULT_DEV_HOST}:${process.env.PORT || DEFAULT_DEV_API_PORT}`; -const defaultUiOrigin = `${process.env.UI_HOST || DEFAULT_DEV_HOST}${process.env.UI_PORT || DEFAULT_DEV_UI_PORT ? `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}` : ''}`; -const defaultAllowedOrigins = [ - defaultUiOrigin, - defaultApiOrigin, -]; -const allowedOrigins = [...new Set(readListEnv('ALLOWED_ORIGINS', defaultAllowedOrigins))]; - -if (authCookieSameSite === 'none' && !authCookieSecure) { - throw new Error('AUTH_COOKIE_SECURE must be true when AUTH_COOKIE_SAME_SITE is none'); -} - -if (isProductionLike && !authCookieSecure) { - throw new Error('AUTH_COOKIE_SECURE must be true in production-like environments'); -} - -if (isProductionLike && !process.env.ALLOWED_ORIGINS) { - throw new Error('ALLOWED_ORIGINS must be configured in production-like environments'); -} - -const config = { - gcloud: { - bucket: process.env.GCLOUD_BUCKET || '', - hash: process.env.GCLOUD_HASH || '' - }, - bcrypt: { - saltRounds: BCRYPT_SALT_ROUNDS - }, - admin_pass: process.env.SEED_ADMIN_PASSWORD, - user_pass: process.env.SEED_USER_PASSWORD, - admin_email: process.env.SEED_ADMIN_EMAIL, - providers: AUTH_PROVIDERS, - auth: { - allowedOrigins, - accessCookieName: process.env.AUTH_ACCESS_COOKIE_NAME || AUTH_COOKIE_NAME, - accessTokenMaxAgeMs: authCookieMaxAgeMs, - cookieDomain: process.env.AUTH_COOKIE_DOMAIN || undefined, - cookiePath: AUTH_COOKIE_PATH, - cookieSameSite: authCookieSameSite, - cookieSecure: authCookieSecure, - refreshCookieName: process.env.AUTH_REFRESH_COOKIE_NAME || AUTH_REFRESH_COOKIE_NAME, - refreshTokenBytes: REFRESH_TOKEN_BYTES, - refreshTokenHashAlgorithm: REFRESH_TOKEN_HASH_ALGORITHM, - refreshTokenMaxAgeMs: readNumberEnv('AUTH_REFRESH_TOKEN_MAX_AGE_MS', REFRESH_TOKEN_EXPIRES_IN_MS), - }, - secret_key: requiredEnv('SECRET_KEY'), - remote: '', - port: isProduction ? "" : (process.env.PORT || DEFAULT_DEV_API_PORT), - serverPort: process.env.NODE_ENV === 'dev_stage' ? DEFAULT_DEV_UI_PORT : (process.env.PORT || DEFAULT_DEV_API_PORT), - hostUI: isProduction ? "" : (process.env.UI_HOST || DEFAULT_DEV_HOST), - portUI: isProduction ? "" : (process.env.UI_PORT || DEFAULT_DEV_UI_PORT), - - portUIProd: isProduction ? "" : `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}`, - - swaggerUI: isProduction ? "" : (process.env.SWAGGER_HOST || DEFAULT_DEV_HOST), - swaggerPort: isProduction ? "" : `:${process.env.SWAGGER_PORT || DEFAULT_DEV_API_PORT}`, - google: { - clientId: process.env.GOOGLE_CLIENT_ID || '', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', - }, - microsoft: { - clientId: process.env.MS_CLIENT_ID || '', - clientSecret: process.env.MS_CLIENT_SECRET || '', - }, - uploadDir: os.tmpdir(), - email: { - from: process.env.EMAIL_FROM || DEFAULT_EMAIL_FROM, - host: process.env.EMAIL_HOST || DEFAULT_EMAIL_HOST, - port: Number(process.env.EMAIL_PORT || DEFAULT_EMAIL_PORT), - auth: { - user: process.env.EMAIL_USER || '', - pass: process.env.EMAIL_PASS, - }, - tls: { - rejectUnauthorized: false - } - }, - roles: { - - super_admin: GENERATED_ROLE_NAMES.SUPER_ADMIN, - - admin: GENERATED_ROLE_NAMES.ADMIN, - - - - user: GENERATED_ROLE_NAMES.FINANCE_OFFICER, - - }, - - project_uuid: process.env.FLATLOGIC_PROJECT_UUID || '', - flHost: isProductionLike ? PRODUCTION_FLATLOGIC_HOST : DEFAULT_FLATLOGIC_HOST, - - - gpt_key: process.env.GPT_KEY || '', -}; - -config.pexelsKey = process.env.PEXELS_KEY || ''; - -config.pexelsQuery = process.env.PEXELS_QUERY || DEFAULT_PEXELS_QUERY; -config.host = isProduction ? config.remote : DEFAULT_DEV_HOST; -config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; -config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; -config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; -config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`; - -module.exports = config; diff --git a/backend/src/constants/app.js b/backend/src/constants/app.js deleted file mode 100644 index f27aee2..0000000 --- a/backend/src/constants/app.js +++ /dev/null @@ -1,55 +0,0 @@ -const DEFAULT_DEV_API_PORT = '8080'; -const DEFAULT_DEV_UI_PORT = '3000'; -const DEFAULT_DEV_HOST = 'http://localhost'; -const DEFAULT_EMAIL_FROM = 'School Chain Manager '; -const DEFAULT_EMAIL_HOST = 'email-smtp.us-east-1.amazonaws.com'; -const DEFAULT_EMAIL_PORT = 587; -const DEFAULT_FLATLOGIC_HOST = 'http://localhost:3000/projects'; -const PRODUCTION_FLATLOGIC_HOST = 'https://flatlogic.com/projects'; -const DEFAULT_PEXELS_QUERY = 'Lighthouse guiding ships at dawn'; -const DEFAULT_PEXELS_PAGE = 1; -const DEFAULT_PEXELS_PER_PAGE = 1; -const PEXELS_IMAGE_ORIENTATION = 'portrait'; -const PEXELS_VIDEO_ORIENTATION = 'portrait'; -const PEXELS_MULTIPLE_IMAGE_ORIENTATION = 'square'; -const PEXELS_IMAGE_SEARCH_URL = 'https://api.pexels.com/v1/search'; -const PEXELS_VIDEO_SEARCH_URL = 'https://api.pexels.com/videos/search'; -const PICSUM_FALLBACK_URL = 'https://picsum.photos/600'; -const PICSUM_FALLBACK_PHOTOGRAPHER = 'Random Picsum'; -const UNKNOWN_PHOTOGRAPHER_LABEL = 'Unknown'; -const PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES = Object.freeze(['home', 'apple', 'pizza', 'mountains', 'cat']); -const PEXELS_FALLBACK_IMAGE = Object.freeze({ - src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', - photographer: 'Yan Krukau', - photographer_url: 'https://www.pexels.com/@yankrukov', -}); -const DEFAULT_DEV_DB_HOST = 'localhost'; -const DEFAULT_DEV_DB_NAME = 'db_school_chain_manager'; -const DEFAULT_DEV_DB_USER = 'postgres'; - -module.exports = { - DEFAULT_DEV_API_PORT, - DEFAULT_DEV_UI_PORT, - DEFAULT_DEV_HOST, - DEFAULT_EMAIL_FROM, - DEFAULT_EMAIL_HOST, - DEFAULT_EMAIL_PORT, - DEFAULT_FLATLOGIC_HOST, - PRODUCTION_FLATLOGIC_HOST, - DEFAULT_PEXELS_QUERY, - DEFAULT_PEXELS_PAGE, - DEFAULT_PEXELS_PER_PAGE, - PEXELS_IMAGE_ORIENTATION, - PEXELS_VIDEO_ORIENTATION, - PEXELS_MULTIPLE_IMAGE_ORIENTATION, - PEXELS_IMAGE_SEARCH_URL, - PEXELS_VIDEO_SEARCH_URL, - PICSUM_FALLBACK_URL, - PICSUM_FALLBACK_PHOTOGRAPHER, - UNKNOWN_PHOTOGRAPHER_LABEL, - PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES, - PEXELS_FALLBACK_IMAGE, - DEFAULT_DEV_DB_HOST, - DEFAULT_DEV_DB_NAME, - DEFAULT_DEV_DB_USER, -}; diff --git a/backend/src/constants/auth.js b/backend/src/constants/auth.js deleted file mode 100644 index eb75589..0000000 --- a/backend/src/constants/auth.js +++ /dev/null @@ -1,43 +0,0 @@ -const AUTH_PROVIDERS = Object.freeze({ - LOCAL: 'local', - GOOGLE: 'google', - MICROSOFT: 'microsoft', -}); - -const BCRYPT_SALT_ROUNDS = 12; -const JWT_EXPIRES_IN = '15m'; -const JWT_EXPIRES_IN_MS = 15 * 60 * 1000; -const AUTH_COOKIE_NAME = 'school_chain_session'; -const AUTH_REFRESH_COOKIE_NAME = 'school_chain_refresh'; -const AUTH_COOKIE_PATH = '/'; -const REFRESH_TOKEN_EXPIRES_IN_MS = 14 * 24 * 60 * 60 * 1000; -const REFRESH_TOKEN_BYTES = 64; -const REFRESH_TOKEN_HASH_ALGORITHM = 'sha256'; -const AUTH_COOKIE_SAME_SITE_VALUES = Object.freeze([ - 'strict', - 'lax', - 'none', -]); -const DEFAULT_AUTH_COOKIE_SAME_SITE = 'lax'; -const UNSAFE_HTTP_METHODS = Object.freeze([ - 'POST', - 'PUT', - 'PATCH', - 'DELETE', -]); - -module.exports = { - AUTH_PROVIDERS, - AUTH_COOKIE_NAME, - AUTH_REFRESH_COOKIE_NAME, - AUTH_COOKIE_PATH, - AUTH_COOKIE_SAME_SITE_VALUES, - BCRYPT_SALT_ROUNDS, - DEFAULT_AUTH_COOKIE_SAME_SITE, - JWT_EXPIRES_IN, - JWT_EXPIRES_IN_MS, - REFRESH_TOKEN_BYTES, - REFRESH_TOKEN_EXPIRES_IN_MS, - REFRESH_TOKEN_HASH_ALGORITHM, - UNSAFE_HTTP_METHODS, -}; diff --git a/backend/src/constants/communications.js b/backend/src/constants/communications.js deleted file mode 100644 index 43c231c..0000000 --- a/backend/src/constants/communications.js +++ /dev/null @@ -1,50 +0,0 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); - -const COMMUNICATION_CHANNELS = Object.freeze({ - IN_APP: 'in_app', -}); - -const COMMUNICATION_AUDIENCES = Object.freeze({ - GUARDIANS: 'guardians', - STAFF: 'staff', -}); - -const COMMUNICATION_STATUSES = Object.freeze({ - SENT: 'sent', -}); - -const COMMUNICATION_RECIPIENT_TYPES = Object.freeze({ - GUARDIAN: 'guardian', -}); - -const COMMUNICATION_EVENT_TYPES = Object.freeze({ - MEETING: 'meeting', - DRILL: 'drill', - EVENT: 'event', - DEADLINE: 'deadline', -}); - -const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, -]); - -const COMMUNICATION_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, -]); - -module.exports = { - COMMUNICATION_AUDIENCES, - COMMUNICATION_CHANNELS, - COMMUNICATION_EVENT_TYPES, - COMMUNICATION_MANAGER_ROLE_NAMES, - COMMUNICATION_RECIPIENT_TYPES, - COMMUNICATION_STATUSES, - COMMUNICATION_TENANT_WIDE_ROLE_NAMES, -}; diff --git a/backend/src/constants/roles.js b/backend/src/constants/roles.js deleted file mode 100644 index 031de2b..0000000 --- a/backend/src/constants/roles.js +++ /dev/null @@ -1,40 +0,0 @@ -const GENERATED_ROLE_NAMES = Object.freeze({ - SUPER_ADMIN: 'Super Administrator', - ADMIN: 'Administrator', - PLATFORM_OWNER: 'Platform Owner', - TENANT_DIRECTOR: 'Tenant Director', - CAMPUS_MANAGER: 'Campus Manager', - ACADEMIC_COORDINATOR: 'Academic Coordinator', - FINANCE_OFFICER: 'Finance Officer', -}); - -const PRODUCT_ROLE_VALUES = Object.freeze({ - TEACHER: 'teacher', - PARA: 'para', - OFFICE: 'office', - DIRECTOR: 'director', - SUPERINTENDENT: 'superintendent', -}); - -const GENERATED_ROLE_TO_PRODUCT_ROLE = Object.freeze({ - [GENERATED_ROLE_NAMES.SUPER_ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.PLATFORM_OWNER]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, - [GENERATED_ROLE_NAMES.TENANT_DIRECTOR]: PRODUCT_ROLE_VALUES.DIRECTOR, - [GENERATED_ROLE_NAMES.CAMPUS_MANAGER]: PRODUCT_ROLE_VALUES.DIRECTOR, - [GENERATED_ROLE_NAMES.ACADEMIC_COORDINATOR]: PRODUCT_ROLE_VALUES.TEACHER, - [GENERATED_ROLE_NAMES.FINANCE_OFFICER]: PRODUCT_ROLE_VALUES.OFFICE, -}); - -const STAFF_TYPE_TO_PRODUCT_ROLE = Object.freeze({ - teacher: PRODUCT_ROLE_VALUES.TEACHER, - admin: PRODUCT_ROLE_VALUES.OFFICE, - support: PRODUCT_ROLE_VALUES.PARA, -}); - -module.exports = { - GENERATED_ROLE_TO_PRODUCT_ROLE, - GENERATED_ROLE_NAMES, - PRODUCT_ROLE_VALUES, - STAFF_TYPE_TO_PRODUCT_ROLE, -}; diff --git a/backend/src/constants/staff-attendance.js b/backend/src/constants/staff-attendance.js deleted file mode 100644 index 69d15c4..0000000 --- a/backend/src/constants/staff-attendance.js +++ /dev/null @@ -1,32 +0,0 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); - -const STAFF_ATTENDANCE_STATUSES = Object.freeze({ - PRESENT: 'present', - LATE: 'late', - ABSENT: 'absent', -}); - -const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, - GENERATED_ROLE_NAMES.TENANT_DIRECTOR, - GENERATED_ROLE_NAMES.CAMPUS_MANAGER, -]); - -const STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ - GENERATED_ROLE_NAMES.SUPER_ADMIN, - GENERATED_ROLE_NAMES.ADMIN, - GENERATED_ROLE_NAMES.PLATFORM_OWNER, -]); - -const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90; -const STAFF_ATTENDANCE_MAX_LIMIT = 366; - -module.exports = { - STAFF_ATTENDANCE_DEFAULT_LIMIT, - STAFF_ATTENDANCE_MAX_LIMIT, - STAFF_ATTENDANCE_REPORT_ROLE_NAMES, - STAFF_ATTENDANCE_STATUSES, - STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, -}; diff --git a/backend/src/constants/user-progress.js b/backend/src/constants/user-progress.js deleted file mode 100644 index d4220bc..0000000 --- a/backend/src/constants/user-progress.js +++ /dev/null @@ -1,11 +0,0 @@ -const USER_PROGRESS_TYPES = Object.freeze({ - SIGN_LEARNED: 'sign_learned', - ZONE_CHECKIN: 'zone_checkin', -}); - -const ZONE_CHECKIN_ITEM_ID = 'current'; - -module.exports = { - USER_PROGRESS_TYPES, - ZONE_CHECKIN_ITEM_ID, -}; diff --git a/backend/src/db/api/academic_years.js b/backend/src/db/api/academic_years.js deleted file mode 100644 index bf31f15..0000000 --- a/backend/src/db/api/academic_years.js +++ /dev/null @@ -1,532 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Academic_yearsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const academic_years = await db.academic_years.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - start_date: data.start_date - || - null - , - - end_date: data.end_date - || - null - , - - current: data.current - || - false - - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await academic_years.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - - - - - - return academic_years; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const academic_yearsData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - start_date: item.start_date - || - null - , - - end_date: item.end_date - || - null - , - - current: item.current - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const academic_years = await db.academic_years.bulkCreate(academic_yearsData, { transaction }); - - // For each item created, replace relation files - - - return academic_years; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const academic_years = await db.academic_years.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.start_date !== undefined) updatePayload.start_date = data.start_date; - - - if (data.end_date !== undefined) updatePayload.end_date = data.end_date; - - - if (data.current !== undefined) updatePayload.current = data.current; - - - updatePayload.updatedById = currentUser.id; - - await academic_years.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await academic_years.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - - - - - - - return academic_years; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const academic_years = await db.academic_years.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of academic_years) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of academic_years) { - await record.destroy({transaction}); - } - }); - - - return academic_years; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const academic_years = await db.academic_years.findByPk(id, options); - - await academic_years.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await academic_years.destroy({ - transaction - }); - - return academic_years; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const academic_years = await db.academic_years.findOne( - { where }, - { transaction }, - ); - - if (!academic_years) { - return academic_years; - } - - const output = academic_years.get({plain: true}); - - - - - - - - - - - - - - output.classes_academic_year = await academic_years.getClasses_academic_year({ - transaction - }); - - - - - output.timetables_academic_year = await academic_years.getTimetables_academic_year({ - transaction - }); - - - - - - output.fee_plans_academic_year = await academic_years.getFee_plans_academic_year({ - transaction - }); - - - - - - - - - - - output.organization = await academic_years.getOrganization({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'academic_years', - 'name', - filter.name, - ), - }; - } - - - - - if (filter.calendarStart && filter.calendarEnd) { - where = { - ...where, - [Op.or]: [ - { - start_date: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - { - end_date: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - ], - }; - } - - - - if (filter.start_dateRange) { - const [start, end] = filter.start_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - start_date: { - ...where.start_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - start_date: { - ...where.start_date, - [Op.lte]: end, - }, - }; - } - } - - if (filter.end_dateRange) { - const [start, end] = filter.end_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - end_date: { - ...where.end_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - end_date: { - ...where.end_date, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.current) { - where = { - ...where, - current: filter.current, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.academic_years.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'academic_years', - 'name', - query, - ), - ], - }; - } - - const records = await db.academic_years.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/academic_years.ts b/backend/src/db/api/academic_years.ts new file mode 100644 index 0000000..9a53685 --- /dev/null +++ b/backend/src/db/api/academic_years.ts @@ -0,0 +1,323 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { AcademicYears } from '@/db/models/academic_years'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type AcademicYearsData = Partial> & { + organization?: string | null; +}; + +interface AcademicYearsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + calendarStart?: string; + calendarEnd?: string; + start_dateRange?: Array; + end_dateRange?: Array; + active?: boolean | string; + current?: boolean | string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Academic_yearsDBApi { + static async create( + data: AcademicYearsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const academic_years = await db.academic_years.create( + { + id: data.id || undefined, + name: data.name || null, + start_date: data.start_date || null, + end_date: data.end_date || null, + current: data.current || false, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await academic_years.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + + return academic_years; + } + + static async bulkImport( + data: AcademicYearsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const academic_yearsData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + start_date: item.start_date || null, + end_date: item.end_date || null, + current: item.current || false, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.academic_years.bulkCreate(academic_yearsData, { transaction }); + } + + static async update( + id: string, + data: AcademicYearsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const academic_years = await db.academic_years.findByPk(id, { transaction }); + + if (!academic_years) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.start_date !== undefined) + updatePayload.start_date = data.start_date; + if (data.end_date !== undefined) updatePayload.end_date = data.end_date; + if (data.current !== undefined) updatePayload.current = data.current; + + updatePayload.updatedById = currentUser.id; + + await academic_years.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await academic_years.setOrganization(orgId ?? undefined, { transaction }); + } + + return academic_years; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.academic_years, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.academic_years, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const academic_years = await db.academic_years.findOne({ + where, + transaction, + }); + + if (!academic_years) { + return null; + } + + const output: Record = academic_years.get({ plain: true }); + + const [ + classes_academic_year, + timetables_academic_year, + fee_plans_academic_year, + organization, + ] = await Promise.all([ + academic_years.getClasses_academic_year({ transaction }), + academic_years.getTimetables_academic_year({ transaction }), + academic_years.getFee_plans_academic_year({ transaction }), + academic_years.getOrganization({ transaction }), + ]); + output.classes_academic_year = classes_academic_year; + output.timetables_academic_year = timetables_academic_year; + output.fee_plans_academic_year = fee_plans_academic_year; + output.organization = organization; + + return output; + } + + static async findAll( + filter: AcademicYearsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: AcademicYears[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('academic_years', 'name', filter.name), + }; + } + if (filter.calendarStart && filter.calendarEnd) { + where = { + ...where, + [Op.or]: [ + { + start_date: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + { + end_date: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + ], + }; + } + if (filter.start_dateRange) { + const [start, end] = filter.start_dateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, start_date: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + start_date: { + ...(typeof where.start_date === 'object' ? where.start_date : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.end_dateRange) { + const [start, end] = filter.end_dateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, end_date: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + end_date: { + ...(typeof where.end_date === 'object' ? where.end_date : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.current) { + where = { ...where, current: filter.current }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.academic_years.findAndCountAll({ + where, + include: [{ model: db.organizations, as: 'organization' }], + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.academic_years, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Academic_yearsDBApi; diff --git a/backend/src/db/api/assessment_results.js b/backend/src/db/api/assessment_results.js deleted file mode 100644 index 767e191..0000000 --- a/backend/src/db/api/assessment_results.js +++ /dev/null @@ -1,537 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Assessment_resultsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const assessment_results = await db.assessment_results.create( - { - id: data.id || undefined, - - score: data.score - || - null - , - - grade_letter: data.grade_letter - || - null - , - - remarks: data.remarks - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await assessment_results.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await assessment_results.setAssessment( data.assessment || null, { - transaction, - }); - - await assessment_results.setStudent( data.student || null, { - transaction, - }); - - - - - - - return assessment_results; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const assessment_resultsData = data.map((item, index) => ({ - id: item.id || undefined, - - score: item.score - || - null - , - - grade_letter: item.grade_letter - || - null - , - - remarks: item.remarks - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const assessment_results = await db.assessment_results.bulkCreate(assessment_resultsData, { transaction }); - - // For each item created, replace relation files - - - return assessment_results; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const assessment_results = await db.assessment_results.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.score !== undefined) updatePayload.score = data.score; - - - if (data.grade_letter !== undefined) updatePayload.grade_letter = data.grade_letter; - - - if (data.remarks !== undefined) updatePayload.remarks = data.remarks; - - - updatePayload.updatedById = currentUser.id; - - await assessment_results.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await assessment_results.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.assessment !== undefined) { - await assessment_results.setAssessment( - - data.assessment, - - { transaction } - ); - } - - if (data.student !== undefined) { - await assessment_results.setStudent( - - data.student, - - { transaction } - ); - } - - - - - - - - return assessment_results; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const assessment_results = await db.assessment_results.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of assessment_results) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of assessment_results) { - await record.destroy({transaction}); - } - }); - - - return assessment_results; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const assessment_results = await db.assessment_results.findByPk(id, options); - - await assessment_results.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await assessment_results.destroy({ - transaction - }); - - return assessment_results; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const assessment_results = await db.assessment_results.findOne( - { where }, - { transaction }, - ); - - if (!assessment_results) { - return assessment_results; - } - - const output = assessment_results.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await assessment_results.getOrganization({ - transaction - }); - - - output.assessment = await assessment_results.getAssessment({ - transaction - }); - - - output.student = await assessment_results.getStudent({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.assessments, - as: 'assessment', - - where: filter.assessment ? { - [Op.or]: [ - { id: { [Op.in]: filter.assessment.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.assessment.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.students, - as: 'student', - - where: filter.student ? { - [Op.or]: [ - { id: { [Op.in]: filter.student.split('|').map(term => Utils.uuid(term)) } }, - { - student_number: { - [Op.or]: filter.student.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.remarks) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'assessment_results', - 'remarks', - filter.remarks, - ), - }; - } - - - - - - - if (filter.scoreRange) { - const [start, end] = filter.scoreRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - score: { - ...where.score, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - score: { - ...where.score, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.grade_letter) { - where = { - ...where, - grade_letter: filter.grade_letter, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.assessment_results.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'assessment_results', - 'grade_letter', - query, - ), - ], - }; - } - - const records = await db.assessment_results.findAll({ - attributes: [ 'id', 'grade_letter' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['grade_letter', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.grade_letter, - })); - } - - -}; - diff --git a/backend/src/db/api/assessment_results.ts b/backend/src/db/api/assessment_results.ts new file mode 100644 index 0000000..8ba6f35 --- /dev/null +++ b/backend/src/db/api/assessment_results.ts @@ -0,0 +1,359 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { AssessmentResults } from '@/db/models/assessment_results'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type AssessmentResultsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + assessment?: string | null; + student?: string | null; +}; + +interface AssessmentResultsFilter { + limit?: number | string; + page?: number | string; + id?: string; + remarks?: string; + scoreRange?: Array; + active?: boolean | string; + grade_letter?: string; + assessment?: string; + student?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Assessment_resultsDBApi { + static async create( + data: AssessmentResultsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const assessment_results = await db.assessment_results.create( + { + id: data.id || undefined, + score: data.score || null, + grade_letter: data.grade_letter || null, + remarks: data.remarks || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await assessment_results.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await assessment_results.setAssessment(data.assessment ?? undefined, { + transaction, + }); + await assessment_results.setStudent(data.student ?? undefined, { + transaction, + }); + + return assessment_results; + } + + static async bulkImport( + data: AssessmentResultsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const assessment_resultsData = data.map((item, index) => ({ + id: item.id || undefined, + score: item.score || null, + grade_letter: item.grade_letter || null, + remarks: item.remarks || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.assessment_results.bulkCreate(assessment_resultsData, { + transaction, + }); + } + + static async update( + id: string, + data: AssessmentResultsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const assessment_results = await db.assessment_results.findByPk(id, { + transaction, + }); + + if (!assessment_results) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.score !== undefined) updatePayload.score = data.score; + if (data.grade_letter !== undefined) + updatePayload.grade_letter = data.grade_letter; + if (data.remarks !== undefined) updatePayload.remarks = data.remarks; + + updatePayload.updatedById = currentUser.id; + + await assessment_results.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await assessment_results.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.assessment !== undefined) { + await assessment_results.setAssessment(data.assessment ?? undefined, { + transaction, + }); + } + if (data.student !== undefined) { + await assessment_results.setStudent(data.student ?? undefined, { + transaction, + }); + } + + return assessment_results; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.assessment_results, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.assessment_results, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const assessment_results = await db.assessment_results.findOne({ + where, + transaction, + }); + + if (!assessment_results) { + return null; + } + + const output: Record = assessment_results.get({ + plain: true, + }); + + const [organization, assessment, student] = await Promise.all([ + assessment_results.getOrganization({ transaction }), + assessment_results.getAssessment({ transaction }), + assessment_results.getStudent({ transaction }), + ]); + output.organization = organization; + output.assessment = assessment; + output.student = student; + + return output; + } + + static async findAll( + filter: AssessmentResultsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: AssessmentResults[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.assessments, + as: 'assessment', + where: filter.assessment + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.assessment + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.assessment + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.students, + as: 'student', + where: filter.student + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + student_number: { + [Op.or]: filter.student + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.remarks) { + where = { + ...where, + [Op.and]: Utils.ilike('assessment_results', 'remarks', filter.remarks), + }; + } + if (filter.scoreRange) { + const [start, end] = filter.scoreRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, score: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + score: { + ...(typeof where.score === 'object' ? where.score : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.grade_letter) { + where = { ...where, grade_letter: filter.grade_letter }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.assessment_results.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.assessment_results, + 'grade_letter', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Assessment_resultsDBApi; diff --git a/backend/src/db/api/assessments.js b/backend/src/db/api/assessments.js deleted file mode 100644 index 7564087..0000000 --- a/backend/src/db/api/assessments.js +++ /dev/null @@ -1,682 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class AssessmentsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const assessments = await db.assessments.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - assessment_type: data.assessment_type - || - null - , - - assigned_at: data.assigned_at - || - null - , - - due_at: data.due_at - || - null - , - - max_score: data.max_score - || - null - , - - status: data.status - || - null - , - - instructions: data.instructions - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await assessments.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await assessments.setClass_subject( data.class_subject || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.assessments.getTableName(), - belongsToColumn: 'attachments', - belongsToId: assessments.id, - }, - data.attachments, - options, - ); - - - return assessments; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const assessmentsData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - assessment_type: item.assessment_type - || - null - , - - assigned_at: item.assigned_at - || - null - , - - due_at: item.due_at - || - null - , - - max_score: item.max_score - || - null - , - - status: item.status - || - null - , - - instructions: item.instructions - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const assessments = await db.assessments.bulkCreate(assessmentsData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < assessments.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.assessments.getTableName(), - belongsToColumn: 'attachments', - belongsToId: assessments[i].id, - }, - data[i].attachments, - options, - ); - } - - - return assessments; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const assessments = await db.assessments.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.assessment_type !== undefined) updatePayload.assessment_type = data.assessment_type; - - - if (data.assigned_at !== undefined) updatePayload.assigned_at = data.assigned_at; - - - if (data.due_at !== undefined) updatePayload.due_at = data.due_at; - - - if (data.max_score !== undefined) updatePayload.max_score = data.max_score; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - if (data.instructions !== undefined) updatePayload.instructions = data.instructions; - - - updatePayload.updatedById = currentUser.id; - - await assessments.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await assessments.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.class_subject !== undefined) { - await assessments.setClass_subject( - - data.class_subject, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.assessments.getTableName(), - belongsToColumn: 'attachments', - belongsToId: assessments.id, - }, - data.attachments, - options, - ); - - - return assessments; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const assessments = await db.assessments.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of assessments) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of assessments) { - await record.destroy({transaction}); - } - }); - - - return assessments; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const assessments = await db.assessments.findByPk(id, options); - - await assessments.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await assessments.destroy({ - transaction - }); - - return assessments; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const assessments = await db.assessments.findOne( - { where }, - { transaction }, - ); - - if (!assessments) { - return assessments; - } - - const output = assessments.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - output.assessment_results_assessment = await assessments.getAssessment_results_assessment({ - transaction - }); - - - - - - - output.organization = await assessments.getOrganization({ - transaction - }); - - - output.class_subject = await assessments.getClass_subject({ - transaction - }); - - - output.attachments = await assessments.getAttachments({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.class_subjects, - as: 'class_subject', - - where: filter.class_subject ? { - [Op.or]: [ - { id: { [Op.in]: filter.class_subject.split('|').map(term => Utils.uuid(term)) } }, - { - status: { - [Op.or]: filter.class_subject.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'attachments', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'assessments', - 'name', - filter.name, - ), - }; - } - - if (filter.instructions) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'assessments', - 'instructions', - filter.instructions, - ), - }; - } - - - - - if (filter.calendarStart && filter.calendarEnd) { - where = { - ...where, - [Op.or]: [ - { - assigned_at: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - { - due_at: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - ], - }; - } - - - - if (filter.assigned_atRange) { - const [start, end] = filter.assigned_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - assigned_at: { - ...where.assigned_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - assigned_at: { - ...where.assigned_at, - [Op.lte]: end, - }, - }; - } - } - - if (filter.due_atRange) { - const [start, end] = filter.due_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - due_at: { - ...where.due_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - due_at: { - ...where.due_at, - [Op.lte]: end, - }, - }; - } - } - - if (filter.max_scoreRange) { - const [start, end] = filter.max_scoreRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - max_score: { - ...where.max_score, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - max_score: { - ...where.max_score, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.assessment_type) { - where = { - ...where, - assessment_type: filter.assessment_type, - }; - } - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.assessments.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'assessments', - 'name', - query, - ), - ], - }; - } - - const records = await db.assessments.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/assessments.ts b/backend/src/db/api/assessments.ts new file mode 100644 index 0000000..8f9b399 --- /dev/null +++ b/backend/src/db/api/assessments.ts @@ -0,0 +1,441 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Assessments } from '@/db/models/assessments'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type AssessmentsData = Partial> & { + organization?: string | null; + class_subject?: string | null; + attachments?: FileInput | FileInput[] | null; +}; + +interface AssessmentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + instructions?: string; + calendarStart?: string; + calendarEnd?: string; + assigned_atRange?: Array; + due_atRange?: Array; + max_scoreRange?: Array; + active?: boolean | string; + assessment_type?: string; + status?: string; + class_subject?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function assessmentsTableName(): string { + const name = db.assessments.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class AssessmentsDBApi { + static async create( + data: AssessmentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const assessments = await db.assessments.create( + { + id: data.id || undefined, + name: data.name || null, + assessment_type: data.assessment_type || null, + assigned_at: data.assigned_at || null, + due_at: data.due_at || null, + max_score: data.max_score || null, + status: data.status || null, + instructions: data.instructions || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await assessments.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await assessments.setClass_subject(data.class_subject ?? undefined, { + transaction, + }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: assessmentsTableName(), + belongsToColumn: 'attachments', + belongsToId: assessments.id, + }, + data.attachments, + options, + ); + + return assessments; + } + + static async bulkImport( + data: AssessmentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const assessmentsData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + assessment_type: item.assessment_type || null, + assigned_at: item.assigned_at || null, + due_at: item.due_at || null, + max_score: item.max_score || null, + status: item.status || null, + instructions: item.instructions || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const assessments = await db.assessments.bulkCreate(assessmentsData, { + transaction, + }); + + for (let i = 0; i < assessments.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: assessmentsTableName(), + belongsToColumn: 'attachments', + belongsToId: assessments[i].id, + }, + data[i].attachments, + options, + ); + } + + return assessments; + } + + static async update( + id: string, + data: AssessmentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const assessments = await db.assessments.findByPk(id, { transaction }); + + if (!assessments) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.assessment_type !== undefined) + updatePayload.assessment_type = data.assessment_type; + if (data.assigned_at !== undefined) + updatePayload.assigned_at = data.assigned_at; + if (data.due_at !== undefined) updatePayload.due_at = data.due_at; + if (data.max_score !== undefined) updatePayload.max_score = data.max_score; + if (data.status !== undefined) updatePayload.status = data.status; + if (data.instructions !== undefined) + updatePayload.instructions = data.instructions; + + updatePayload.updatedById = currentUser.id; + + await assessments.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await assessments.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.class_subject !== undefined) { + await assessments.setClass_subject(data.class_subject ?? undefined, { + transaction, + }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: assessmentsTableName(), + belongsToColumn: 'attachments', + belongsToId: assessments.id, + }, + data.attachments, + options, + ); + + return assessments; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.assessments, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.assessments, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const assessments = await db.assessments.findOne({ where, transaction }); + + if (!assessments) { + return null; + } + + const output: Record = assessments.get({ plain: true }); + + const [ + assessment_results_assessment, + organization, + class_subject, + attachments, + ] = await Promise.all([ + assessments.getAssessment_results_assessment({ transaction }), + assessments.getOrganization({ transaction }), + assessments.getClass_subject({ transaction }), + assessments.getAttachments({ transaction }), + ]); + output.assessment_results_assessment = assessment_results_assessment; + output.organization = organization; + output.class_subject = class_subject; + output.attachments = attachments; + + return output; + } + + static async findAll( + filter: AssessmentsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Assessments[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.class_subjects, + as: 'class_subject', + where: filter.class_subject + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class_subject + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + status: { + [Op.or]: filter.class_subject + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'attachments' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('assessments', 'name', filter.name), + }; + } + if (filter.instructions) { + where = { + ...where, + [Op.and]: Utils.ilike('assessments', 'instructions', filter.instructions), + }; + } + if (filter.calendarStart && filter.calendarEnd) { + where = { + ...where, + [Op.or]: [ + { + assigned_at: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + { + due_at: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + ], + }; + } + if (filter.assigned_atRange) { + const [start, end] = filter.assigned_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, assigned_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + assigned_at: { + ...(typeof where.assigned_at === 'object' ? where.assigned_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.due_atRange) { + const [start, end] = filter.due_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, due_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + due_at: { + ...(typeof where.due_at === 'object' ? where.due_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.max_scoreRange) { + const [start, end] = filter.max_scoreRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, max_score: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + max_score: { + ...(typeof where.max_score === 'object' ? where.max_score : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.assessment_type) { + where = { ...where, assessment_type: filter.assessment_type }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.assessments.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.assessments, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default AssessmentsDBApi; diff --git a/backend/src/db/api/attendance_records.js b/backend/src/db/api/attendance_records.js deleted file mode 100644 index 9340123..0000000 --- a/backend/src/db/api/attendance_records.js +++ /dev/null @@ -1,537 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Attendance_recordsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const attendance_records = await db.attendance_records.create( - { - id: data.id || undefined, - - status: data.status - || - null - , - - minutes_late: data.minutes_late - || - null - , - - remarks: data.remarks - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await attendance_records.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await attendance_records.setAttendance_session( data.attendance_session || null, { - transaction, - }); - - await attendance_records.setStudent( data.student || null, { - transaction, - }); - - - - - - - return attendance_records; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const attendance_recordsData = data.map((item, index) => ({ - id: item.id || undefined, - - status: item.status - || - null - , - - minutes_late: item.minutes_late - || - null - , - - remarks: item.remarks - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const attendance_records = await db.attendance_records.bulkCreate(attendance_recordsData, { transaction }); - - // For each item created, replace relation files - - - return attendance_records; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const attendance_records = await db.attendance_records.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.status !== undefined) updatePayload.status = data.status; - - - if (data.minutes_late !== undefined) updatePayload.minutes_late = data.minutes_late; - - - if (data.remarks !== undefined) updatePayload.remarks = data.remarks; - - - updatePayload.updatedById = currentUser.id; - - await attendance_records.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await attendance_records.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.attendance_session !== undefined) { - await attendance_records.setAttendance_session( - - data.attendance_session, - - { transaction } - ); - } - - if (data.student !== undefined) { - await attendance_records.setStudent( - - data.student, - - { transaction } - ); - } - - - - - - - - return attendance_records; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const attendance_records = await db.attendance_records.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of attendance_records) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of attendance_records) { - await record.destroy({transaction}); - } - }); - - - return attendance_records; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const attendance_records = await db.attendance_records.findByPk(id, options); - - await attendance_records.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await attendance_records.destroy({ - transaction - }); - - return attendance_records; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const attendance_records = await db.attendance_records.findOne( - { where }, - { transaction }, - ); - - if (!attendance_records) { - return attendance_records; - } - - const output = attendance_records.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await attendance_records.getOrganization({ - transaction - }); - - - output.attendance_session = await attendance_records.getAttendance_session({ - transaction - }); - - - output.student = await attendance_records.getStudent({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.attendance_sessions, - as: 'attendance_session', - - where: filter.attendance_session ? { - [Op.or]: [ - { id: { [Op.in]: filter.attendance_session.split('|').map(term => Utils.uuid(term)) } }, - { - session_type: { - [Op.or]: filter.attendance_session.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.students, - as: 'student', - - where: filter.student ? { - [Op.or]: [ - { id: { [Op.in]: filter.student.split('|').map(term => Utils.uuid(term)) } }, - { - student_number: { - [Op.or]: filter.student.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.remarks) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'attendance_records', - 'remarks', - filter.remarks, - ), - }; - } - - - - - - - if (filter.minutes_lateRange) { - const [start, end] = filter.minutes_lateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - minutes_late: { - ...where.minutes_late, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - minutes_late: { - ...where.minutes_late, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.attendance_records.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'attendance_records', - 'status', - query, - ), - ], - }; - } - - const records = await db.attendance_records.findAll({ - attributes: [ 'id', 'status' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['status', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.status, - })); - } - - -}; - diff --git a/backend/src/db/api/attendance_records.ts b/backend/src/db/api/attendance_records.ts new file mode 100644 index 0000000..7e270fd --- /dev/null +++ b/backend/src/db/api/attendance_records.ts @@ -0,0 +1,363 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { AttendanceRecords } from '@/db/models/attendance_records'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type AttendanceRecordsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + attendance_session?: string | null; + student?: string | null; +}; + +interface AttendanceRecordsFilter { + limit?: number | string; + page?: number | string; + id?: string; + remarks?: string; + minutes_lateRange?: Array; + active?: boolean | string; + status?: string; + attendance_session?: string; + student?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Attendance_recordsDBApi { + static async create( + data: AttendanceRecordsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const attendance_records = await db.attendance_records.create( + { + id: data.id || undefined, + status: data.status || null, + minutes_late: data.minutes_late || null, + remarks: data.remarks || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await attendance_records.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await attendance_records.setAttendance_session( + data.attendance_session ?? undefined, + { transaction }, + ); + await attendance_records.setStudent(data.student ?? undefined, { + transaction, + }); + + return attendance_records; + } + + static async bulkImport( + data: AttendanceRecordsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const attendance_recordsData = data.map((item, index) => ({ + id: item.id || undefined, + status: item.status || null, + minutes_late: item.minutes_late || null, + remarks: item.remarks || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.attendance_records.bulkCreate(attendance_recordsData, { + transaction, + }); + } + + static async update( + id: string, + data: AttendanceRecordsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const attendance_records = await db.attendance_records.findByPk(id, { + transaction, + }); + + if (!attendance_records) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.status !== undefined) updatePayload.status = data.status; + if (data.minutes_late !== undefined) + updatePayload.minutes_late = data.minutes_late; + if (data.remarks !== undefined) updatePayload.remarks = data.remarks; + + updatePayload.updatedById = currentUser.id; + + await attendance_records.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await attendance_records.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.attendance_session !== undefined) { + await attendance_records.setAttendance_session( + data.attendance_session ?? undefined, + { transaction }, + ); + } + if (data.student !== undefined) { + await attendance_records.setStudent(data.student ?? undefined, { + transaction, + }); + } + + return attendance_records; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.attendance_records, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.attendance_records, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const attendance_records = await db.attendance_records.findOne({ + where, + transaction, + }); + + if (!attendance_records) { + return null; + } + + const output: Record = attendance_records.get({ + plain: true, + }); + + const [organization, attendance_session, student] = await Promise.all([ + attendance_records.getOrganization({ transaction }), + attendance_records.getAttendance_session({ transaction }), + attendance_records.getStudent({ transaction }), + ]); + output.organization = organization; + output.attendance_session = attendance_session; + output.student = student; + + return output; + } + + static async findAll( + filter: AttendanceRecordsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: AttendanceRecords[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.attendance_sessions, + as: 'attendance_session', + where: filter.attendance_session + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.attendance_session + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + session_type: { + [Op.or]: filter.attendance_session + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.students, + as: 'student', + where: filter.student + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + student_number: { + [Op.or]: filter.student + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.remarks) { + where = { + ...where, + [Op.and]: Utils.ilike('attendance_records', 'remarks', filter.remarks), + }; + } + if (filter.minutes_lateRange) { + const [start, end] = filter.minutes_lateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, minutes_late: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + minutes_late: { + ...(typeof where.minutes_late === 'object' + ? where.minutes_late + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.attendance_records.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.attendance_records, + 'status', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Attendance_recordsDBApi; diff --git a/backend/src/db/api/attendance_sessions.js b/backend/src/db/api/attendance_sessions.js deleted file mode 100644 index 269ebad..0000000 --- a/backend/src/db/api/attendance_sessions.js +++ /dev/null @@ -1,615 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Attendance_sessionsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const attendance_sessions = await db.attendance_sessions.create( - { - id: data.id || undefined, - - session_date: data.session_date - || - null - , - - session_type: data.session_type - || - null - , - - notes: data.notes - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await attendance_sessions.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await attendance_sessions.setCampus( data.campus || null, { - transaction, - }); - - await attendance_sessions.setClass( data.class || null, { - transaction, - }); - - await attendance_sessions.setClass_subject( data.class_subject || null, { - transaction, - }); - - await attendance_sessions.setTaken_by( data.taken_by || null, { - transaction, - }); - - - - - - - return attendance_sessions; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const attendance_sessionsData = data.map((item, index) => ({ - id: item.id || undefined, - - session_date: item.session_date - || - null - , - - session_type: item.session_type - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const attendance_sessions = await db.attendance_sessions.bulkCreate(attendance_sessionsData, { transaction }); - - // For each item created, replace relation files - - - return attendance_sessions; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const attendance_sessions = await db.attendance_sessions.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.session_date !== undefined) updatePayload.session_date = data.session_date; - - - if (data.session_type !== undefined) updatePayload.session_type = data.session_type; - - - if (data.notes !== undefined) updatePayload.notes = data.notes; - - - updatePayload.updatedById = currentUser.id; - - await attendance_sessions.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await attendance_sessions.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await attendance_sessions.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.class !== undefined) { - await attendance_sessions.setClass( - - data.class, - - { transaction } - ); - } - - if (data.class_subject !== undefined) { - await attendance_sessions.setClass_subject( - - data.class_subject, - - { transaction } - ); - } - - if (data.taken_by !== undefined) { - await attendance_sessions.setTaken_by( - - data.taken_by, - - { transaction } - ); - } - - - - - - - - return attendance_sessions; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const attendance_sessions = await db.attendance_sessions.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of attendance_sessions) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of attendance_sessions) { - await record.destroy({transaction}); - } - }); - - - return attendance_sessions; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const attendance_sessions = await db.attendance_sessions.findByPk(id, options); - - await attendance_sessions.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await attendance_sessions.destroy({ - transaction - }); - - return attendance_sessions; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const attendance_sessions = await db.attendance_sessions.findOne( - { where }, - { transaction }, - ); - - if (!attendance_sessions) { - return attendance_sessions; - } - - const output = attendance_sessions.get({plain: true}); - - - - - - - - - - - - - - - - - - - - output.attendance_records_attendance_session = await attendance_sessions.getAttendance_records_attendance_session({ - transaction - }); - - - - - - - - - - - - output.organization = await attendance_sessions.getOrganization({ - transaction - }); - - - output.campus = await attendance_sessions.getCampus({ - transaction - }); - - - output.class = await attendance_sessions.getClass({ - transaction - }); - - - output.class_subject = await attendance_sessions.getClass_subject({ - transaction - }); - - - output.taken_by = await attendance_sessions.getTaken_by({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.classes, - as: 'class', - - where: filter.class ? { - [Op.or]: [ - { id: { [Op.in]: filter.class.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.class.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.class_subjects, - as: 'class_subject', - - where: filter.class_subject ? { - [Op.or]: [ - { id: { [Op.in]: filter.class_subject.split('|').map(term => Utils.uuid(term)) } }, - { - status: { - [Op.or]: filter.class_subject.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.staff, - as: 'taken_by', - - where: filter.taken_by ? { - [Op.or]: [ - { id: { [Op.in]: filter.taken_by.split('|').map(term => Utils.uuid(term)) } }, - { - employee_number: { - [Op.or]: filter.taken_by.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'attendance_sessions', - 'notes', - filter.notes, - ), - }; - } - - - - - - - if (filter.session_dateRange) { - const [start, end] = filter.session_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - session_date: { - ...where.session_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - session_date: { - ...where.session_date, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.session_type) { - where = { - ...where, - session_type: filter.session_type, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.attendance_sessions.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'attendance_sessions', - 'session_type', - query, - ), - ], - }; - } - - const records = await db.attendance_sessions.findAll({ - attributes: [ 'id', 'session_type' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['session_type', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.session_type, - })); - } - - -}; - diff --git a/backend/src/db/api/attendance_sessions.ts b/backend/src/db/api/attendance_sessions.ts new file mode 100644 index 0000000..b7570ff --- /dev/null +++ b/backend/src/db/api/attendance_sessions.ts @@ -0,0 +1,443 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { AttendanceSessions } from '@/db/models/attendance_sessions'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type AttendanceSessionsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + campus?: string | null; + class?: string | null; + class_subject?: string | null; + taken_by?: string | null; +}; + +interface AttendanceSessionsFilter { + limit?: number | string; + page?: number | string; + id?: string; + notes?: string; + session_dateRange?: Array; + active?: boolean | string; + session_type?: string; + campus?: string; + class?: string; + class_subject?: string; + taken_by?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Attendance_sessionsDBApi { + static async create( + data: AttendanceSessionsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const attendance_sessions = await db.attendance_sessions.create( + { + id: data.id || undefined, + session_date: data.session_date || null, + session_type: data.session_type || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await attendance_sessions.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await attendance_sessions.setCampus(data.campus ?? undefined, { + transaction, + }); + await attendance_sessions.setClass(data.class ?? undefined, { transaction }); + await attendance_sessions.setClass_subject(data.class_subject ?? undefined, { + transaction, + }); + await attendance_sessions.setTaken_by(data.taken_by ?? undefined, { + transaction, + }); + + return attendance_sessions; + } + + static async bulkImport( + data: AttendanceSessionsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const attendance_sessionsData = data.map((item, index) => ({ + id: item.id || undefined, + session_date: item.session_date || null, + session_type: item.session_type || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.attendance_sessions.bulkCreate(attendance_sessionsData, { + transaction, + }); + } + + static async update( + id: string, + data: AttendanceSessionsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const attendance_sessions = await db.attendance_sessions.findByPk(id, { + transaction, + }); + + if (!attendance_sessions) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.session_date !== undefined) + updatePayload.session_date = data.session_date; + if (data.session_type !== undefined) + updatePayload.session_type = data.session_type; + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await attendance_sessions.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await attendance_sessions.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.campus !== undefined) { + await attendance_sessions.setCampus(data.campus ?? undefined, { + transaction, + }); + } + if (data.class !== undefined) { + await attendance_sessions.setClass(data.class ?? undefined, { + transaction, + }); + } + if (data.class_subject !== undefined) { + await attendance_sessions.setClass_subject( + data.class_subject ?? undefined, + { transaction }, + ); + } + if (data.taken_by !== undefined) { + await attendance_sessions.setTaken_by(data.taken_by ?? undefined, { + transaction, + }); + } + + return attendance_sessions; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.attendance_sessions, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.attendance_sessions, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const attendance_sessions = await db.attendance_sessions.findOne({ + where, + transaction, + }); + + if (!attendance_sessions) { + return null; + } + + const output: Record = attendance_sessions.get({ + plain: true, + }); + + const [ + attendance_records_attendance_session, + organization, + campus, + class_, + class_subject, + taken_by, + ] = await Promise.all([ + attendance_sessions.getAttendance_records_attendance_session({ + transaction, + }), + attendance_sessions.getOrganization({ transaction }), + attendance_sessions.getCampus({ transaction }), + attendance_sessions.getClass({ transaction }), + attendance_sessions.getClass_subject({ transaction }), + attendance_sessions.getTaken_by({ transaction }), + ]); + output.attendance_records_attendance_session = + attendance_records_attendance_session; + output.organization = organization; + output.campus = campus; + output.class = class_; + output.class_subject = class_subject; + output.taken_by = taken_by; + + return output; + } + + static async findAll( + filter: AttendanceSessionsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: AttendanceSessions[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.classes, + as: 'class', + where: filter.class + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.class + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.class_subjects, + as: 'class_subject', + where: filter.class_subject + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class_subject + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + status: { + [Op.or]: filter.class_subject + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.staff, + as: 'taken_by', + where: filter.taken_by + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.taken_by + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + employee_number: { + [Op.or]: filter.taken_by + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('attendance_sessions', 'notes', filter.notes), + }; + } + if (filter.session_dateRange) { + const [start, end] = filter.session_dateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, session_date: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + session_date: { + ...(typeof where.session_date === 'object' + ? where.session_date + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.session_type) { + where = { ...where, session_type: filter.session_type }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.attendance_sessions.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.attendance_sessions, + 'session_type', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Attendance_sessionsDBApi; diff --git a/backend/src/db/api/auth_refresh_tokens.js b/backend/src/db/api/auth_refresh_tokens.ts similarity index 53% rename from backend/src/db/api/auth_refresh_tokens.js rename to backend/src/db/api/auth_refresh_tokens.ts index c6b68b9..ee688b0 100644 --- a/backend/src/db/api/auth_refresh_tokens.js +++ b/backend/src/db/api/auth_refresh_tokens.ts @@ -1,7 +1,23 @@ -const db = require('../models'); +import db from '@/db/models'; +import type { AuthRefreshTokens } from '@/db/models/auth_refresh_tokens'; +import type { DbApiOptions } from '@/db/api/types'; -module.exports = class AuthRefreshTokensDBApi { - static async create(data, options = {}) { +interface CreateData { + userId: string; + organizationId?: string | null; + tokenHash: string; + familyId: string; + previousTokenId?: string | null; + userAgent?: string | null; + ipAddress?: string | null; + expiresAt: Date; +} + +class AuthRefreshTokensDBApi { + static async create( + data: CreateData, + options: DbApiOptions = {}, + ): Promise { return db.auth_refresh_tokens.create( { userId: data.userId, @@ -19,14 +35,21 @@ module.exports = class AuthRefreshTokensDBApi { ); } - static async findByHash(tokenHash, options = {}) { + static async findByHash( + tokenHash: string, + options: DbApiOptions = {}, + ): Promise { return db.auth_refresh_tokens.findOne({ where: { tokenHash }, transaction: options.transaction, }); } - static async revoke(id, replacedByTokenId, options = {}) { + static async revoke( + id: string, + replacedByTokenId: string | null, + options: DbApiOptions = {}, + ): Promise<[affectedCount: number]> { return db.auth_refresh_tokens.update( { revokedAt: new Date(), @@ -39,7 +62,10 @@ module.exports = class AuthRefreshTokensDBApi { ); } - static async revokeFamily(familyId, options = {}) { + static async revokeFamily( + familyId: string, + options: DbApiOptions = {}, + ): Promise<[affectedCount: number]> { return db.auth_refresh_tokens.update( { revokedAt: new Date(), @@ -53,4 +79,6 @@ module.exports = class AuthRefreshTokensDBApi { }, ); } -}; +} + +export default AuthRefreshTokensDBApi; diff --git a/backend/src/db/api/campuses.js b/backend/src/db/api/campuses.js deleted file mode 100644 index 1a00377..0000000 --- a/backend/src/db/api/campuses.js +++ /dev/null @@ -1,651 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class CampusesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const campuses = await db.campuses.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - code: data.code - || - null - , - - address: data.address - || - null - , - - phone: data.phone - || - null - , - - email: data.email - || - null - , - - mascot: data.mascot - || - null - , - - color: data.color - || - null - , - - bgGradient: data.bgGradient - || - null - , - - borderColor: data.borderColor - || - null - , - - textColor: data.textColor - || - null - , - - bgLight: data.bgLight - || - null - , - - description: data.description - || - null - , - - isOnline: data.isOnline - || - false - , - - active: data.active - || - false - - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await campuses.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - - - - - - return campuses; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const campusesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - address: item.address - || - null - , - - phone: item.phone - || - null - , - - email: item.email - || - null - , - - mascot: item.mascot - || - null - , - - color: item.color - || - null - , - - bgGradient: item.bgGradient - || - null - , - - borderColor: item.borderColor - || - null - , - - textColor: item.textColor - || - null - , - - bgLight: item.bgLight - || - null - , - - description: item.description - || - null - , - - isOnline: item.isOnline - || - false - , - - active: item.active - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const campuses = await db.campuses.bulkCreate(campusesData, { transaction }); - - // For each item created, replace relation files - - - return campuses; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const campuses = await db.campuses.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.code !== undefined) updatePayload.code = data.code; - - - if (data.address !== undefined) updatePayload.address = data.address; - - - if (data.phone !== undefined) updatePayload.phone = data.phone; - - - if (data.email !== undefined) updatePayload.email = data.email; - - if (data.mascot !== undefined) updatePayload.mascot = data.mascot; - - if (data.color !== undefined) updatePayload.color = data.color; - - if (data.bgGradient !== undefined) updatePayload.bgGradient = data.bgGradient; - - if (data.borderColor !== undefined) updatePayload.borderColor = data.borderColor; - - if (data.textColor !== undefined) updatePayload.textColor = data.textColor; - - if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight; - - if (data.description !== undefined) updatePayload.description = data.description; - - if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline; - - - if (data.active !== undefined) updatePayload.active = data.active; - - - updatePayload.updatedById = currentUser.id; - - await campuses.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await campuses.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - - - - - - - return campuses; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const campuses = await db.campuses.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of campuses) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of campuses) { - await record.destroy({transaction}); - } - }); - - - return campuses; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const campuses = await db.campuses.findByPk(id, options); - - await campuses.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await campuses.destroy({ - transaction - }); - - return campuses; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const campuses = await db.campuses.findOne( - { where }, - { transaction }, - ); - - if (!campuses) { - return campuses; - } - - const output = campuses.get({plain: true}); - - - - - - - - - - - output.students_campus = await campuses.getStudents_campus({ - transaction - }); - - - - output.staff_campus = await campuses.getStaff_campus({ - transaction - }); - - - output.classes_campus = await campuses.getClasses_campus({ - transaction - }); - - - - - output.timetables_campus = await campuses.getTimetables_campus({ - transaction - }); - - - - output.attendance_sessions_campus = await campuses.getAttendance_sessions_campus({ - transaction - }); - - - - - output.invoices_campus = await campuses.getInvoices_campus({ - transaction - }); - - - - - - output.messages_campus = await campuses.getMessages_campus({ - transaction - }); - - - - output.documents_campus = await campuses.getDocuments_campus({ - transaction - }); - - - - output.organization = await campuses.getOrganization({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'campuses', - 'name', - filter.name, - ), - }; - } - - if (filter.code) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'campuses', - 'code', - filter.code, - ), - }; - } - - if (filter.address) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'campuses', - 'address', - filter.address, - ), - }; - } - - if (filter.phone) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'campuses', - 'phone', - filter.phone, - ), - }; - } - - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'campuses', - 'email', - filter.email, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.active) { - where = { - ...where, - active: filter.active, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.campuses.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'campuses', - 'name', - query, - ), - ], - }; - } - - const records = await db.campuses.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; diff --git a/backend/src/db/api/campuses.ts b/backend/src/db/api/campuses.ts new file mode 100644 index 0000000..1b28c82 --- /dev/null +++ b/backend/src/db/api/campuses.ts @@ -0,0 +1,342 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import ValidationError from '@/shared/errors/validation'; +import type { Campuses } from '@/db/models/campuses'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type CampusesData = Partial> & { + organization?: string | null; +}; + +interface CampusesFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + code?: string; + address?: string; + phone?: string; + email?: string; + active?: boolean | string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class CampusesDBApi { + static async create( + data: CampusesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + if (data.name == null || data.code == null) { + throw new ValidationError(); + } + + const campuses = await db.campuses.create( + { + id: data.id || undefined, + name: data.name, + code: data.code, + address: data.address || null, + phone: data.phone || null, + email: data.email || null, + mascot: data.mascot || null, + color: data.color || null, + bgGradient: data.bgGradient || null, + borderColor: data.borderColor || null, + textColor: data.textColor || null, + bgLight: data.bgLight || null, + description: data.description || null, + isOnline: data.isOnline || false, + active: data.active || false, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await campuses.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + + return campuses; + } + + static async bulkImport( + data: CampusesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const campusesData = data.map((item, index) => { + if (item.name == null || item.code == null) { + throw new ValidationError(); + } + return { + id: item.id || undefined, + name: item.name, + code: item.code, + address: item.address || null, + phone: item.phone || null, + email: item.email || null, + mascot: item.mascot || null, + color: item.color || null, + bgGradient: item.bgGradient || null, + borderColor: item.borderColor || null, + textColor: item.textColor || null, + bgLight: item.bgLight || null, + description: item.description || null, + isOnline: item.isOnline || false, + active: item.active || false, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + }; + }); + + return db.campuses.bulkCreate(campusesData, { transaction }); + } + + static async update( + id: string, + data: CampusesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const campuses = await db.campuses.findByPk(id, { transaction }); + + if (!campuses) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.code !== undefined) updatePayload.code = data.code; + if (data.address !== undefined) updatePayload.address = data.address; + if (data.phone !== undefined) updatePayload.phone = data.phone; + if (data.email !== undefined) updatePayload.email = data.email; + if (data.mascot !== undefined) updatePayload.mascot = data.mascot; + if (data.color !== undefined) updatePayload.color = data.color; + if (data.bgGradient !== undefined) updatePayload.bgGradient = data.bgGradient; + if (data.borderColor !== undefined) + updatePayload.borderColor = data.borderColor; + if (data.textColor !== undefined) updatePayload.textColor = data.textColor; + if (data.bgLight !== undefined) updatePayload.bgLight = data.bgLight; + if (data.description !== undefined) + updatePayload.description = data.description; + if (data.isOnline !== undefined) updatePayload.isOnline = data.isOnline; + if (data.active !== undefined) updatePayload.active = data.active; + + updatePayload.updatedById = currentUser.id; + + await campuses.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await campuses.setOrganization(orgId ?? undefined, { transaction }); + } + + return campuses; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.campuses, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.campuses, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const campuses = await db.campuses.findOne({ where, transaction }); + + if (!campuses) { + return null; + } + + const output: Record = campuses.get({ plain: true }); + + const [ + students_campus, + staff_campus, + classes_campus, + timetables_campus, + attendance_sessions_campus, + invoices_campus, + messages_campus, + documents_campus, + organization, + ] = await Promise.all([ + campuses.getStudents_campus({ transaction }), + campuses.getStaff_campus({ transaction }), + campuses.getClasses_campus({ transaction }), + campuses.getTimetables_campus({ transaction }), + campuses.getAttendance_sessions_campus({ transaction }), + campuses.getInvoices_campus({ transaction }), + campuses.getMessages_campus({ transaction }), + campuses.getDocuments_campus({ transaction }), + campuses.getOrganization({ transaction }), + ]); + output.students_campus = students_campus; + output.staff_campus = staff_campus; + output.classes_campus = classes_campus; + output.timetables_campus = timetables_campus; + output.attendance_sessions_campus = attendance_sessions_campus; + output.invoices_campus = invoices_campus; + output.messages_campus = messages_campus; + output.documents_campus = documents_campus; + output.organization = organization; + + return output; + } + + static async findAll( + filter: CampusesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Campuses[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { ...where, [Op.and]: Utils.ilike('campuses', 'name', filter.name) }; + } + if (filter.code) { + where = { ...where, [Op.and]: Utils.ilike('campuses', 'code', filter.code) }; + } + if (filter.address) { + where = { + ...where, + [Op.and]: Utils.ilike('campuses', 'address', filter.address), + }; + } + if (filter.phone) { + where = { + ...where, + [Op.and]: Utils.ilike('campuses', 'phone', filter.phone), + }; + } + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('campuses', 'email', filter.email), + }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.campuses.findAndCountAll({ + where, + include: [{ model: db.organizations, as: 'organization' }], + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.campuses, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default CampusesDBApi; diff --git a/backend/src/db/api/class_enrollments.js b/backend/src/db/api/class_enrollments.js deleted file mode 100644 index 8f7a1e2..0000000 --- a/backend/src/db/api/class_enrollments.js +++ /dev/null @@ -1,550 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Class_enrollmentsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const class_enrollments = await db.class_enrollments.create( - { - id: data.id || undefined, - - enrolled_on: data.enrolled_on - || - null - , - - ended_on: data.ended_on - || - null - , - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await class_enrollments.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await class_enrollments.setClass( data.class || null, { - transaction, - }); - - await class_enrollments.setStudent( data.student || null, { - transaction, - }); - - - - - - - return class_enrollments; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const class_enrollmentsData = data.map((item, index) => ({ - id: item.id || undefined, - - enrolled_on: item.enrolled_on - || - null - , - - ended_on: item.ended_on - || - null - , - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const class_enrollments = await db.class_enrollments.bulkCreate(class_enrollmentsData, { transaction }); - - // For each item created, replace relation files - - - return class_enrollments; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const class_enrollments = await db.class_enrollments.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.enrolled_on !== undefined) updatePayload.enrolled_on = data.enrolled_on; - - - if (data.ended_on !== undefined) updatePayload.ended_on = data.ended_on; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await class_enrollments.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await class_enrollments.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.class !== undefined) { - await class_enrollments.setClass( - - data.class, - - { transaction } - ); - } - - if (data.student !== undefined) { - await class_enrollments.setStudent( - - data.student, - - { transaction } - ); - } - - - - - - - - return class_enrollments; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const class_enrollments = await db.class_enrollments.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of class_enrollments) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of class_enrollments) { - await record.destroy({transaction}); - } - }); - - - return class_enrollments; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const class_enrollments = await db.class_enrollments.findByPk(id, options); - - await class_enrollments.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await class_enrollments.destroy({ - transaction - }); - - return class_enrollments; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const class_enrollments = await db.class_enrollments.findOne( - { where }, - { transaction }, - ); - - if (!class_enrollments) { - return class_enrollments; - } - - const output = class_enrollments.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await class_enrollments.getOrganization({ - transaction - }); - - - output.class = await class_enrollments.getClass({ - transaction - }); - - - output.student = await class_enrollments.getStudent({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.classes, - as: 'class', - - where: filter.class ? { - [Op.or]: [ - { id: { [Op.in]: filter.class.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.class.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.students, - as: 'student', - - where: filter.student ? { - [Op.or]: [ - { id: { [Op.in]: filter.student.split('|').map(term => Utils.uuid(term)) } }, - { - student_number: { - [Op.or]: filter.student.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - - - - - - if (filter.enrolled_onRange) { - const [start, end] = filter.enrolled_onRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - enrolled_on: { - ...where.enrolled_on, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - enrolled_on: { - ...where.enrolled_on, - [Op.lte]: end, - }, - }; - } - } - - if (filter.ended_onRange) { - const [start, end] = filter.ended_onRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ended_on: { - ...where.ended_on, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ended_on: { - ...where.ended_on, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.class_enrollments.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'class_enrollments', - 'status', - query, - ), - ], - }; - } - - const records = await db.class_enrollments.findAll({ - attributes: [ 'id', 'status' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['status', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.status, - })); - } - - -}; - diff --git a/backend/src/db/api/class_enrollments.ts b/backend/src/db/api/class_enrollments.ts new file mode 100644 index 0000000..eb071b7 --- /dev/null +++ b/backend/src/db/api/class_enrollments.ts @@ -0,0 +1,364 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { ClassEnrollments } from '@/db/models/class_enrollments'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type ClassEnrollmentsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + class?: string | null; + student?: string | null; +}; + +interface ClassEnrollmentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + class?: string; + student?: string; + enrolled_onRange?: Array; + ended_onRange?: Array; + active?: boolean | string; + status?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Class_enrollmentsDBApi { + static async create( + data: ClassEnrollmentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const class_enrollments = await db.class_enrollments.create( + { + id: data.id || undefined, + enrolled_on: data.enrolled_on || null, + ended_on: data.ended_on || null, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await class_enrollments.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await class_enrollments.setClass(data.class ?? undefined, { transaction }); + await class_enrollments.setStudent(data.student ?? undefined, { + transaction, + }); + + return class_enrollments; + } + + static async bulkImport( + data: ClassEnrollmentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const class_enrollmentsData = data.map((item, index) => ({ + id: item.id || undefined, + enrolled_on: item.enrolled_on || null, + ended_on: item.ended_on || null, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.class_enrollments.bulkCreate(class_enrollmentsData, { + transaction, + }); + } + + static async update( + id: string, + data: ClassEnrollmentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const class_enrollments = await db.class_enrollments.findByPk(id, { + transaction, + }); + + if (!class_enrollments) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.enrolled_on !== undefined) + updatePayload.enrolled_on = data.enrolled_on; + if (data.ended_on !== undefined) updatePayload.ended_on = data.ended_on; + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await class_enrollments.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await class_enrollments.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.class !== undefined) { + await class_enrollments.setClass(data.class ?? undefined, { + transaction, + }); + } + if (data.student !== undefined) { + await class_enrollments.setStudent(data.student ?? undefined, { + transaction, + }); + } + + return class_enrollments; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.class_enrollments, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.class_enrollments, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const class_enrollments = await db.class_enrollments.findOne({ + where, + transaction, + }); + + if (!class_enrollments) { + return null; + } + + const output: Record = class_enrollments.get({ + plain: true, + }); + + const [organization, class_, student] = await Promise.all([ + class_enrollments.getOrganization({ transaction }), + class_enrollments.getClass({ transaction }), + class_enrollments.getStudent({ transaction }), + ]); + output.organization = organization; + output.class = class_; + output.student = student; + + return output; + } + + static async findAll( + filter: ClassEnrollmentsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: ClassEnrollments[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.classes, + as: 'class', + where: filter.class + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.class + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.students, + as: 'student', + where: filter.student + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + student_number: { + [Op.or]: filter.student + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.enrolled_onRange) { + const [start, end] = filter.enrolled_onRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, enrolled_on: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + enrolled_on: { + ...(typeof where.enrolled_on === 'object' ? where.enrolled_on : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.ended_onRange) { + const [start, end] = filter.ended_onRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, ended_on: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ended_on: { + ...(typeof where.ended_on === 'object' ? where.ended_on : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.class_enrollments.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.class_enrollments, + 'status', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Class_enrollmentsDBApi; diff --git a/backend/src/db/api/class_subjects.js b/backend/src/db/api/class_subjects.js deleted file mode 100644 index 0fe879b..0000000 --- a/backend/src/db/api/class_subjects.js +++ /dev/null @@ -1,525 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Class_subjectsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const class_subjects = await db.class_subjects.create( - { - id: data.id || undefined, - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await class_subjects.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await class_subjects.setClass( data.class || null, { - transaction, - }); - - await class_subjects.setSubject( data.subject || null, { - transaction, - }); - - await class_subjects.setTeacher( data.teacher || null, { - transaction, - }); - - - - - - - return class_subjects; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const class_subjectsData = data.map((item, index) => ({ - id: item.id || undefined, - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const class_subjects = await db.class_subjects.bulkCreate(class_subjectsData, { transaction }); - - // For each item created, replace relation files - - - return class_subjects; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const class_subjects = await db.class_subjects.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await class_subjects.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await class_subjects.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.class !== undefined) { - await class_subjects.setClass( - - data.class, - - { transaction } - ); - } - - if (data.subject !== undefined) { - await class_subjects.setSubject( - - data.subject, - - { transaction } - ); - } - - if (data.teacher !== undefined) { - await class_subjects.setTeacher( - - data.teacher, - - { transaction } - ); - } - - - - - - - - return class_subjects; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const class_subjects = await db.class_subjects.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of class_subjects) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of class_subjects) { - await record.destroy({transaction}); - } - }); - - - return class_subjects; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const class_subjects = await db.class_subjects.findByPk(id, options); - - await class_subjects.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await class_subjects.destroy({ - transaction - }); - - return class_subjects; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const class_subjects = await db.class_subjects.findOne( - { where }, - { transaction }, - ); - - if (!class_subjects) { - return class_subjects; - } - - const output = class_subjects.get({plain: true}); - - - - - - - - - - - - - - - - - - output.timetable_periods_class_subject = await class_subjects.getTimetable_periods_class_subject({ - transaction - }); - - - output.attendance_sessions_class_subject = await class_subjects.getAttendance_sessions_class_subject({ - transaction - }); - - - - - - - output.assessments_class_subject = await class_subjects.getAssessments_class_subject({ - transaction - }); - - - - - - - - output.organization = await class_subjects.getOrganization({ - transaction - }); - - - output.class = await class_subjects.getClass({ - transaction - }); - - - output.subject = await class_subjects.getSubject({ - transaction - }); - - - output.teacher = await class_subjects.getTeacher({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.classes, - as: 'class', - - where: filter.class ? { - [Op.or]: [ - { id: { [Op.in]: filter.class.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.class.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.subjects, - as: 'subject', - - where: filter.subject ? { - [Op.or]: [ - { id: { [Op.in]: filter.subject.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.subject.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.staff, - as: 'teacher', - - where: filter.teacher ? { - [Op.or]: [ - { id: { [Op.in]: filter.teacher.split('|').map(term => Utils.uuid(term)) } }, - { - employee_number: { - [Op.or]: filter.teacher.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.class_subjects.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'class_subjects', - 'status', - query, - ), - ], - }; - } - - const records = await db.class_subjects.findAll({ - attributes: [ 'id', 'status' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['status', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.status, - })); - } - - -}; - diff --git a/backend/src/db/api/class_subjects.ts b/backend/src/db/api/class_subjects.ts new file mode 100644 index 0000000..eef796f --- /dev/null +++ b/backend/src/db/api/class_subjects.ts @@ -0,0 +1,358 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { ClassSubjects } from '@/db/models/class_subjects'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type ClassSubjectsData = Partial> & { + organization?: string | null; + class?: string | null; + subject?: string | null; + teacher?: string | null; +}; + +interface ClassSubjectsFilter { + limit?: number | string; + page?: number | string; + id?: string; + class?: string; + subject?: string; + teacher?: string; + active?: boolean | string; + status?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Class_subjectsDBApi { + static async create( + data: ClassSubjectsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const class_subjects = await db.class_subjects.create( + { + id: data.id || undefined, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await class_subjects.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await class_subjects.setClass(data.class ?? undefined, { transaction }); + await class_subjects.setSubject(data.subject ?? undefined, { transaction }); + await class_subjects.setTeacher(data.teacher ?? undefined, { transaction }); + + return class_subjects; + } + + static async bulkImport( + data: ClassSubjectsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const class_subjectsData = data.map((item, index) => ({ + id: item.id || undefined, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.class_subjects.bulkCreate(class_subjectsData, { transaction }); + } + + static async update( + id: string, + data: ClassSubjectsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const class_subjects = await db.class_subjects.findByPk(id, { transaction }); + + if (!class_subjects) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await class_subjects.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await class_subjects.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.class !== undefined) { + await class_subjects.setClass(data.class ?? undefined, { transaction }); + } + if (data.subject !== undefined) { + await class_subjects.setSubject(data.subject ?? undefined, { + transaction, + }); + } + if (data.teacher !== undefined) { + await class_subjects.setTeacher(data.teacher ?? undefined, { + transaction, + }); + } + + return class_subjects; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.class_subjects, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.class_subjects, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const class_subjects = await db.class_subjects.findOne({ + where, + transaction, + }); + + if (!class_subjects) { + return null; + } + + const output: Record = class_subjects.get({ plain: true }); + + const [ + timetable_periods_class_subject, + attendance_sessions_class_subject, + assessments_class_subject, + organization, + class_, + subject, + teacher, + ] = await Promise.all([ + class_subjects.getTimetable_periods_class_subject({ transaction }), + class_subjects.getAttendance_sessions_class_subject({ transaction }), + class_subjects.getAssessments_class_subject({ transaction }), + class_subjects.getOrganization({ transaction }), + class_subjects.getClass({ transaction }), + class_subjects.getSubject({ transaction }), + class_subjects.getTeacher({ transaction }), + ]); + output.timetable_periods_class_subject = timetable_periods_class_subject; + output.attendance_sessions_class_subject = + attendance_sessions_class_subject; + output.assessments_class_subject = assessments_class_subject; + output.organization = organization; + output.class = class_; + output.subject = subject; + output.teacher = teacher; + + return output; + } + + static async findAll( + filter: ClassSubjectsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: ClassSubjects[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.classes, + as: 'class', + where: filter.class + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.class + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.subjects, + as: 'subject', + where: filter.subject + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.subject.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.subject + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.staff, + as: 'teacher', + where: filter.teacher + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.teacher.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + employee_number: { + [Op.or]: filter.teacher + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.class_subjects.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.class_subjects, + 'status', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Class_subjectsDBApi; diff --git a/backend/src/db/api/classes.js b/backend/src/db/api/classes.js deleted file mode 100644 index 6cc26fb..0000000 --- a/backend/src/db/api/classes.js +++ /dev/null @@ -1,647 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class ClassesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const classes = await db.classes.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - section: data.section - || - null - , - - capacity: data.capacity - || - null - , - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await classes.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await classes.setCampus( data.campus || null, { - transaction, - }); - - await classes.setAcademic_year( data.academic_year || null, { - transaction, - }); - - await classes.setGrade( data.grade || null, { - transaction, - }); - - await classes.setHomeroom_teacher( data.homeroom_teacher || null, { - transaction, - }); - - - - - - - return classes; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const classesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - section: item.section - || - null - , - - capacity: item.capacity - || - null - , - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const classes = await db.classes.bulkCreate(classesData, { transaction }); - - // For each item created, replace relation files - - - return classes; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const classes = await db.classes.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.section !== undefined) updatePayload.section = data.section; - - - if (data.capacity !== undefined) updatePayload.capacity = data.capacity; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await classes.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await classes.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await classes.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.academic_year !== undefined) { - await classes.setAcademic_year( - - data.academic_year, - - { transaction } - ); - } - - if (data.grade !== undefined) { - await classes.setGrade( - - data.grade, - - { transaction } - ); - } - - if (data.homeroom_teacher !== undefined) { - await classes.setHomeroom_teacher( - - data.homeroom_teacher, - - { transaction } - ); - } - - - - - - - - return classes; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const classes = await db.classes.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of classes) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of classes) { - await record.destroy({transaction}); - } - }); - - - return classes; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const classes = await db.classes.findByPk(id, options); - - await classes.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await classes.destroy({ - transaction - }); - - return classes; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const classes = await db.classes.findOne( - { where }, - { transaction }, - ); - - if (!classes) { - return classes; - } - - const output = classes.get({plain: true}); - - - - - - - - - - - - - - - output.class_enrollments_class = await classes.getClass_enrollments_class({ - transaction - }); - - - output.class_subjects_class = await classes.getClass_subjects_class({ - transaction - }); - - - - - output.attendance_sessions_class = await classes.getAttendance_sessions_class({ - transaction - }); - - - - - - - - - - - - - output.organization = await classes.getOrganization({ - transaction - }); - - - output.campus = await classes.getCampus({ - transaction - }); - - - output.academic_year = await classes.getAcademic_year({ - transaction - }); - - - output.grade = await classes.getGrade({ - transaction - }); - - - output.homeroom_teacher = await classes.getHomeroom_teacher({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.academic_years, - as: 'academic_year', - - where: filter.academic_year ? { - [Op.or]: [ - { id: { [Op.in]: filter.academic_year.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.academic_year.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.grades, - as: 'grade', - - where: filter.grade ? { - [Op.or]: [ - { id: { [Op.in]: filter.grade.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.grade.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.staff, - as: 'homeroom_teacher', - - where: filter.homeroom_teacher ? { - [Op.or]: [ - { id: { [Op.in]: filter.homeroom_teacher.split('|').map(term => Utils.uuid(term)) } }, - { - employee_number: { - [Op.or]: filter.homeroom_teacher.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'classes', - 'name', - filter.name, - ), - }; - } - - if (filter.section) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'classes', - 'section', - filter.section, - ), - }; - } - - - - - - - if (filter.capacityRange) { - const [start, end] = filter.capacityRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - capacity: { - ...where.capacity, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - capacity: { - ...where.capacity, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.classes.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'classes', - 'name', - query, - ), - ], - }; - } - - const records = await db.classes.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/classes.ts b/backend/src/db/api/classes.ts new file mode 100644 index 0000000..febaf85 --- /dev/null +++ b/backend/src/db/api/classes.ts @@ -0,0 +1,431 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Classes } from '@/db/models/classes'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type ClassesData = Partial> & { + organization?: string | null; + campus?: string | null; + academic_year?: string | null; + grade?: string | null; + homeroom_teacher?: string | null; +}; + +interface ClassesFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + section?: string; + capacityRange?: Array; + active?: boolean | string; + status?: string; + campus?: string; + academic_year?: string; + grade?: string; + homeroom_teacher?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class ClassesDBApi { + static async create( + data: ClassesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const classes = await db.classes.create( + { + id: data.id || undefined, + name: data.name || null, + section: data.section || null, + capacity: data.capacity || null, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await classes.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await classes.setCampus(data.campus ?? undefined, { transaction }); + await classes.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + await classes.setGrade(data.grade ?? undefined, { transaction }); + await classes.setHomeroom_teacher(data.homeroom_teacher ?? undefined, { + transaction, + }); + + return classes; + } + + static async bulkImport( + data: ClassesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const classesData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + section: item.section || null, + capacity: item.capacity || null, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.classes.bulkCreate(classesData, { transaction }); + } + + static async update( + id: string, + data: ClassesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const classes = await db.classes.findByPk(id, { transaction }); + + if (!classes) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.section !== undefined) updatePayload.section = data.section; + if (data.capacity !== undefined) updatePayload.capacity = data.capacity; + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await classes.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await classes.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await classes.setCampus(data.campus ?? undefined, { transaction }); + } + if (data.academic_year !== undefined) { + await classes.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + } + if (data.grade !== undefined) { + await classes.setGrade(data.grade ?? undefined, { transaction }); + } + if (data.homeroom_teacher !== undefined) { + await classes.setHomeroom_teacher(data.homeroom_teacher ?? undefined, { + transaction, + }); + } + + return classes; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.classes, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.classes, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const classes = await db.classes.findOne({ where, transaction }); + + if (!classes) { + return null; + } + + const output: Record = classes.get({ plain: true }); + + const [ + class_enrollments_class, + class_subjects_class, + attendance_sessions_class, + organization, + campus, + academic_year, + grade, + homeroom_teacher, + ] = await Promise.all([ + classes.getClass_enrollments_class({ transaction }), + classes.getClass_subjects_class({ transaction }), + classes.getAttendance_sessions_class({ transaction }), + classes.getOrganization({ transaction }), + classes.getCampus({ transaction }), + classes.getAcademic_year({ transaction }), + classes.getGrade({ transaction }), + classes.getHomeroom_teacher({ transaction }), + ]); + output.class_enrollments_class = class_enrollments_class; + output.class_subjects_class = class_subjects_class; + output.attendance_sessions_class = attendance_sessions_class; + output.organization = organization; + output.campus = campus; + output.academic_year = academic_year; + output.grade = grade; + output.homeroom_teacher = homeroom_teacher; + + return output; + } + + static async findAll( + filter: ClassesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Classes[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.academic_years, + as: 'academic_year', + where: filter.academic_year + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.academic_year + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.academic_year + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.grades, + as: 'grade', + where: filter.grade + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.grade.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.grade + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.staff, + as: 'homeroom_teacher', + where: filter.homeroom_teacher + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.homeroom_teacher + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + employee_number: { + [Op.or]: filter.homeroom_teacher + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('classes', 'name', filter.name), + }; + } + if (filter.section) { + where = { + ...where, + [Op.and]: Utils.ilike('classes', 'section', filter.section), + }; + } + if (filter.capacityRange) { + const [start, end] = filter.capacityRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, capacity: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + capacity: { + ...(typeof where.capacity === 'object' ? where.capacity : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.classes.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.classes, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default ClassesDBApi; diff --git a/backend/src/db/api/documents.js b/backend/src/db/api/documents.js deleted file mode 100644 index b6210bd..0000000 --- a/backend/src/db/api/documents.js +++ /dev/null @@ -1,610 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class DocumentsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const documents = await db.documents.create( - { - id: data.id || undefined, - - entity_type: data.entity_type - || - null - , - - entity_reference: data.entity_reference - || - null - , - - name: data.name - || - null - , - - category: data.category - || - null - , - - uploaded_at: data.uploaded_at - || - null - , - - notes: data.notes - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await documents.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await documents.setCampus( data.campus || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.documents.getTableName(), - belongsToColumn: 'file', - belongsToId: documents.id, - }, - data.file, - options, - ); - - - return documents; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const documentsData = data.map((item, index) => ({ - id: item.id || undefined, - - entity_type: item.entity_type - || - null - , - - entity_reference: item.entity_reference - || - null - , - - name: item.name - || - null - , - - category: item.category - || - null - , - - uploaded_at: item.uploaded_at - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const documents = await db.documents.bulkCreate(documentsData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < documents.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.documents.getTableName(), - belongsToColumn: 'file', - belongsToId: documents[i].id, - }, - data[i].file, - options, - ); - } - - - return documents; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const documents = await db.documents.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.entity_type !== undefined) updatePayload.entity_type = data.entity_type; - - - if (data.entity_reference !== undefined) updatePayload.entity_reference = data.entity_reference; - - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.category !== undefined) updatePayload.category = data.category; - - - if (data.uploaded_at !== undefined) updatePayload.uploaded_at = data.uploaded_at; - - - if (data.notes !== undefined) updatePayload.notes = data.notes; - - - updatePayload.updatedById = currentUser.id; - - await documents.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await documents.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await documents.setCampus( - - data.campus, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.documents.getTableName(), - belongsToColumn: 'file', - belongsToId: documents.id, - }, - data.file, - options, - ); - - - return documents; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const documents = await db.documents.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of documents) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of documents) { - await record.destroy({transaction}); - } - }); - - - return documents; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const documents = await db.documents.findByPk(id, options); - - await documents.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await documents.destroy({ - transaction - }); - - return documents; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const documents = await db.documents.findOne( - { where }, - { transaction }, - ); - - if (!documents) { - return documents; - } - - const output = documents.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await documents.getOrganization({ - transaction - }); - - - output.campus = await documents.getCampus({ - transaction - }); - - - output.file = await documents.getFile({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'file', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.entity_reference) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'documents', - 'entity_reference', - filter.entity_reference, - ), - }; - } - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'documents', - 'name', - filter.name, - ), - }; - } - - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'documents', - 'notes', - filter.notes, - ), - }; - } - - - - - - - if (filter.uploaded_atRange) { - const [start, end] = filter.uploaded_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - uploaded_at: { - ...where.uploaded_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - uploaded_at: { - ...where.uploaded_at, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.entity_type) { - where = { - ...where, - entity_type: filter.entity_type, - }; - } - - if (filter.category) { - where = { - ...where, - category: filter.category, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.documents.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'documents', - 'name', - query, - ), - ], - }; - } - - const records = await db.documents.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/documents.ts b/backend/src/db/api/documents.ts new file mode 100644 index 0000000..3e7cc84 --- /dev/null +++ b/backend/src/db/api/documents.ts @@ -0,0 +1,390 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Documents } from '@/db/models/documents'; +import type { + CurrentUser, + DbApiOptions, + FileInput, +} from '@/db/api/types'; + +type DocumentsData = Partial> & { + organization?: string | null; + campus?: string | null; + file?: FileInput | FileInput[] | null; +}; + +interface DocumentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + entity_reference?: string; + name?: string; + notes?: string; + uploaded_atRange?: Array; + active?: boolean | string; + entity_type?: string; + category?: string; + campus?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function documentsTableName(): string { + const name = db.documents.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class DocumentsDBApi { + static async create( + data: DocumentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const documents = await db.documents.create( + { + id: data.id || undefined, + entity_type: data.entity_type || null, + entity_reference: data.entity_reference || null, + name: data.name || null, + category: data.category || null, + uploaded_at: data.uploaded_at || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await documents.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await documents.setCampus(data.campus ?? undefined, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: documentsTableName(), + belongsToColumn: 'file', + belongsToId: documents.id, + }, + data.file, + options, + ); + + return documents; + } + + static async bulkImport( + data: DocumentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const documentsData = data.map((item, index) => ({ + id: item.id || undefined, + entity_type: item.entity_type || null, + entity_reference: item.entity_reference || null, + name: item.name || null, + category: item.category || null, + uploaded_at: item.uploaded_at || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const documents = await db.documents.bulkCreate(documentsData, { + transaction, + }); + + for (let i = 0; i < documents.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: documentsTableName(), + belongsToColumn: 'file', + belongsToId: documents[i].id, + }, + data[i].file, + options, + ); + } + + return documents; + } + + static async update( + id: string, + data: DocumentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const documents = await db.documents.findByPk(id, { transaction }); + + if (!documents) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.entity_type !== undefined) + updatePayload.entity_type = data.entity_type; + if (data.entity_reference !== undefined) + updatePayload.entity_reference = data.entity_reference; + if (data.name !== undefined) updatePayload.name = data.name; + if (data.category !== undefined) updatePayload.category = data.category; + if (data.uploaded_at !== undefined) + updatePayload.uploaded_at = data.uploaded_at; + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await documents.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await documents.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await documents.setCampus(data.campus ?? undefined, { transaction }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: documentsTableName(), + belongsToColumn: 'file', + belongsToId: documents.id, + }, + data.file, + options, + ); + + return documents; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.documents, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.documents, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const documents = await db.documents.findOne({ where, transaction }); + + if (!documents) { + return null; + } + + const output: Record = documents.get({ plain: true }); + + const [organization, campus, file] = await Promise.all([ + documents.getOrganization({ transaction }), + documents.getCampus({ transaction }), + documents.getFile({ transaction }), + ]); + output.organization = organization; + output.campus = campus; + output.file = file; + + return output; + } + + static async findAll( + filter: DocumentsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Documents[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + // The list DTO (`toDocumentDto`) returns only scalar columns, so we don't + // eager-load organization/file. The campus join is added only when filtering + // by campus, and selects no columns (filter-only, inner join). + const include: Includeable[] = []; + if (filter.campus) { + include.push({ + model: db.campuses, + as: 'campus', + attributes: [], + required: true, + where: { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + }, + }); + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.entity_reference) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'documents', + 'entity_reference', + filter.entity_reference, + ), + }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('documents', 'name', filter.name), + }; + } + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('documents', 'notes', filter.notes), + }; + } + if (filter.uploaded_atRange) { + const [start, end] = filter.uploaded_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, uploaded_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + uploaded_at: { + ...(typeof where.uploaded_at === 'object' ? where.uploaded_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.entity_type) { + where = { ...where, entity_type: filter.entity_type }; + } + if (filter.category) { + where = { ...where, category: filter.category }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.documents.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.documents, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default DocumentsDBApi; diff --git a/backend/src/db/api/fee_plans.js b/backend/src/db/api/fee_plans.js deleted file mode 100644 index 1516fe7..0000000 --- a/backend/src/db/api/fee_plans.js +++ /dev/null @@ -1,587 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Fee_plansDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const fee_plans = await db.fee_plans.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - billing_cycle: data.billing_cycle - || - null - , - - total_amount: data.total_amount - || - null - , - - active: data.active - || - false - - , - - notes: data.notes - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await fee_plans.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await fee_plans.setAcademic_year( data.academic_year || null, { - transaction, - }); - - await fee_plans.setGrade( data.grade || null, { - transaction, - }); - - - - - - - return fee_plans; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const fee_plansData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - billing_cycle: item.billing_cycle - || - null - , - - total_amount: item.total_amount - || - null - , - - active: item.active - || - false - - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const fee_plans = await db.fee_plans.bulkCreate(fee_plansData, { transaction }); - - // For each item created, replace relation files - - - return fee_plans; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const fee_plans = await db.fee_plans.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.billing_cycle !== undefined) updatePayload.billing_cycle = data.billing_cycle; - - - if (data.total_amount !== undefined) updatePayload.total_amount = data.total_amount; - - - if (data.active !== undefined) updatePayload.active = data.active; - - - if (data.notes !== undefined) updatePayload.notes = data.notes; - - - updatePayload.updatedById = currentUser.id; - - await fee_plans.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await fee_plans.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.academic_year !== undefined) { - await fee_plans.setAcademic_year( - - data.academic_year, - - { transaction } - ); - } - - if (data.grade !== undefined) { - await fee_plans.setGrade( - - data.grade, - - { transaction } - ); - } - - - - - - - - return fee_plans; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const fee_plans = await db.fee_plans.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of fee_plans) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of fee_plans) { - await record.destroy({transaction}); - } - }); - - - return fee_plans; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const fee_plans = await db.fee_plans.findByPk(id, options); - - await fee_plans.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await fee_plans.destroy({ - transaction - }); - - return fee_plans; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const fee_plans = await db.fee_plans.findOne( - { where }, - { transaction }, - ); - - if (!fee_plans) { - return fee_plans; - } - - const output = fee_plans.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - output.invoices_fee_plan = await fee_plans.getInvoices_fee_plan({ - transaction - }); - - - - - - - - - - output.organization = await fee_plans.getOrganization({ - transaction - }); - - - output.academic_year = await fee_plans.getAcademic_year({ - transaction - }); - - - output.grade = await fee_plans.getGrade({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.academic_years, - as: 'academic_year', - - where: filter.academic_year ? { - [Op.or]: [ - { id: { [Op.in]: filter.academic_year.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.academic_year.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.grades, - as: 'grade', - - where: filter.grade ? { - [Op.or]: [ - { id: { [Op.in]: filter.grade.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.grade.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'fee_plans', - 'name', - filter.name, - ), - }; - } - - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'fee_plans', - 'notes', - filter.notes, - ), - }; - } - - - - - - - if (filter.total_amountRange) { - const [start, end] = filter.total_amountRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - total_amount: { - ...where.total_amount, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - total_amount: { - ...where.total_amount, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.billing_cycle) { - where = { - ...where, - billing_cycle: filter.billing_cycle, - }; - } - - if (filter.active) { - where = { - ...where, - active: filter.active, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.fee_plans.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'fee_plans', - 'name', - query, - ), - ], - }; - } - - const records = await db.fee_plans.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/fee_plans.ts b/backend/src/db/api/fee_plans.ts new file mode 100644 index 0000000..36aecc8 --- /dev/null +++ b/backend/src/db/api/fee_plans.ts @@ -0,0 +1,363 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { FeePlans } from '@/db/models/fee_plans'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type FeePlansData = Partial> & { + organization?: string | null; + academic_year?: string | null; + grade?: string | null; +}; + +interface FeePlansFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + notes?: string; + total_amountRange?: Array; + active?: boolean | string; + billing_cycle?: string; + academic_year?: string; + grade?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Fee_plansDBApi { + static async create( + data: FeePlansData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const fee_plans = await db.fee_plans.create( + { + id: data.id || undefined, + name: data.name || null, + billing_cycle: data.billing_cycle || null, + total_amount: data.total_amount || null, + active: data.active || false, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await fee_plans.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await fee_plans.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + await fee_plans.setGrade(data.grade ?? undefined, { transaction }); + + return fee_plans; + } + + static async bulkImport( + data: FeePlansData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const fee_plansData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + billing_cycle: item.billing_cycle || null, + total_amount: item.total_amount || null, + active: item.active || false, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.fee_plans.bulkCreate(fee_plansData, { transaction }); + } + + static async update( + id: string, + data: FeePlansData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const fee_plans = await db.fee_plans.findByPk(id, { transaction }); + + if (!fee_plans) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.billing_cycle !== undefined) + updatePayload.billing_cycle = data.billing_cycle; + if (data.total_amount !== undefined) + updatePayload.total_amount = data.total_amount; + if (data.active !== undefined) updatePayload.active = data.active; + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await fee_plans.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await fee_plans.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.academic_year !== undefined) { + await fee_plans.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + } + if (data.grade !== undefined) { + await fee_plans.setGrade(data.grade ?? undefined, { transaction }); + } + + return fee_plans; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.fee_plans, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.fee_plans, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const fee_plans = await db.fee_plans.findOne({ where, transaction }); + + if (!fee_plans) { + return null; + } + + const output: Record = fee_plans.get({ plain: true }); + + const [invoices_fee_plan, organization, academic_year, grade] = + await Promise.all([ + fee_plans.getInvoices_fee_plan({ transaction }), + fee_plans.getOrganization({ transaction }), + fee_plans.getAcademic_year({ transaction }), + fee_plans.getGrade({ transaction }), + ]); + output.invoices_fee_plan = invoices_fee_plan; + output.organization = organization; + output.academic_year = academic_year; + output.grade = grade; + + return output; + } + + static async findAll( + filter: FeePlansFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: FeePlans[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.academic_years, + as: 'academic_year', + where: filter.academic_year + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.academic_year + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.academic_year + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.grades, + as: 'grade', + where: filter.grade + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.grade.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.grade + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('fee_plans', 'name', filter.name), + }; + } + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('fee_plans', 'notes', filter.notes), + }; + } + if (filter.total_amountRange) { + const [start, end] = filter.total_amountRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, total_amount: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + total_amount: { + ...(typeof where.total_amount === 'object' + ? where.total_amount + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.billing_cycle) { + where = { ...where, billing_cycle: filter.billing_cycle }; + } + if (filter.active) { + where = { ...where, active: filter.active }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.fee_plans.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.fee_plans, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Fee_plansDBApi; diff --git a/backend/src/db/api/file.js b/backend/src/db/api/file.js deleted file mode 100644 index f4f7121..0000000 --- a/backend/src/db/api/file.js +++ /dev/null @@ -1,87 +0,0 @@ -const db = require('../models'); -const assert = require('assert'); -const services = require('../../services/file'); - -module.exports = class FileDBApi { - static async replaceRelationFiles( - relation, - rawFiles, - options, - ) { - assert(relation.belongsTo, 'belongsTo is required'); - assert( - relation.belongsToColumn, - 'belongsToColumn is required', - ); - assert(relation.belongsToId, 'belongsToId is required'); - - let files = []; - - if (Array.isArray(rawFiles)) { - files = rawFiles; - } else { - files = rawFiles ? [rawFiles] : []; - } - - await this._removeLegacyFiles(relation, files, options); - await this._addFiles(relation, files, options); - } - - static async _addFiles(relation, files, options) { - const transaction = (options && options.transaction) || undefined; - const currentUser = (options && options.currentUser) || {id: null}; - - const inexistentFiles = files.filter( - (file) => !!file.new, - ); - - for (const file of inexistentFiles) { - await db.file.create( - { - belongsTo: relation.belongsTo, - belongsToColumn: relation.belongsToColumn, - belongsToId: relation.belongsToId, - name: file.name, - sizeInBytes: file.sizeInBytes, - privateUrl: file.privateUrl, - publicUrl: file.publicUrl, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { - transaction, - }, - ); - } - } - - static async _removeLegacyFiles( - relation, - files, - options, - ) { - const transaction = (options && options.transaction) || undefined; - - const filesToDelete = await db.file.findAll({ - where: { - belongsTo: relation.belongsTo, - belongsToId: relation.belongsToId, - belongsToColumn: relation.belongsToColumn, - id: { - [db.Sequelize.Op - .notIn]: files - .filter((file) => !file.new) - .map((file) => file.id) - }, - }, - transaction, - }); - - for (let file of filesToDelete) { - await services.deleteGCloud(file.privateUrl); - await file.destroy({ - transaction, - }); - } - } -}; diff --git a/backend/src/db/api/file.ts b/backend/src/db/api/file.ts new file mode 100644 index 0000000..ec3d9d2 --- /dev/null +++ b/backend/src/db/api/file.ts @@ -0,0 +1,91 @@ +import assert from 'assert'; +import { Op } from 'sequelize'; +import db from '@/db/models'; +import services from '@/services/file'; +import ValidationError from '@/shared/errors/validation'; +import type { DbApiOptions, FileInput, FileRelation } from '@/db/api/types'; + +class FileDBApi { + static async replaceRelationFiles( + relation: FileRelation, + rawFiles: FileInput | FileInput[] | null | undefined, + options?: DbApiOptions, + ): Promise { + assert(relation.belongsTo, 'belongsTo is required'); + assert(relation.belongsToColumn, 'belongsToColumn is required'); + assert(relation.belongsToId, 'belongsToId is required'); + + const files: FileInput[] = Array.isArray(rawFiles) + ? rawFiles + : rawFiles + ? [rawFiles] + : []; + + await this._removeLegacyFiles(relation, files, options); + await this._addFiles(relation, files, options); + } + + static async _addFiles( + relation: FileRelation, + files: FileInput[], + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + const currentUser = options?.currentUser ?? { id: null }; + + const inexistentFiles = files.filter((file) => !!file.new); + + for (const file of inexistentFiles) { + if (!file.name || !file.publicUrl) { + throw new ValidationError('iam.errors.fileNameRequired'); + } + + await db.file.create( + { + belongsTo: relation.belongsTo, + belongsToColumn: relation.belongsToColumn, + belongsToId: relation.belongsToId, + name: file.name, + sizeInBytes: file.sizeInBytes ?? null, + privateUrl: file.privateUrl ?? null, + publicUrl: file.publicUrl, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + } + + static async _removeLegacyFiles( + relation: FileRelation, + files: FileInput[], + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + const filesToDelete = await db.file.findAll({ + where: { + belongsTo: relation.belongsTo, + belongsToId: relation.belongsToId, + belongsToColumn: relation.belongsToColumn, + id: { + [Op.notIn]: files + .filter((file) => !file.new) + .map((file) => file.id) + .filter((id): id is string => Boolean(id)), + }, + }, + transaction, + }); + + for (const file of filesToDelete) { + if (file.privateUrl) { + await services.deleteGCloud(file.privateUrl); + } + await file.destroy({ transaction }); + } + } +} + +export default FileDBApi; diff --git a/backend/src/db/api/grades.js b/backend/src/db/api/grades.js deleted file mode 100644 index 81a2143..0000000 --- a/backend/src/db/api/grades.js +++ /dev/null @@ -1,499 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class GradesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const grades = await db.grades.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - code: data.code - || - null - , - - sort_order: data.sort_order - || - null - , - - description: data.description - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await grades.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - - - - - - return grades; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const gradesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - sort_order: item.sort_order - || - null - , - - description: item.description - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const grades = await db.grades.bulkCreate(gradesData, { transaction }); - - // For each item created, replace relation files - - - return grades; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const grades = await db.grades.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.code !== undefined) updatePayload.code = data.code; - - - if (data.sort_order !== undefined) updatePayload.sort_order = data.sort_order; - - - if (data.description !== undefined) updatePayload.description = data.description; - - - updatePayload.updatedById = currentUser.id; - - await grades.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await grades.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - - - - - - - return grades; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const grades = await db.grades.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of grades) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of grades) { - await record.destroy({transaction}); - } - }); - - - return grades; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const grades = await db.grades.findByPk(id, options); - - await grades.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await grades.destroy({ - transaction - }); - - return grades; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const grades = await db.grades.findOne( - { where }, - { transaction }, - ); - - if (!grades) { - return grades; - } - - const output = grades.get({plain: true}); - - - - - - - - - - - - - - output.classes_grade = await grades.getClasses_grade({ - transaction - }); - - - - - - - - - output.fee_plans_grade = await grades.getFee_plans_grade({ - transaction - }); - - - - - - - - - - - output.organization = await grades.getOrganization({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'grades', - 'name', - filter.name, - ), - }; - } - - if (filter.code) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'grades', - 'code', - filter.code, - ), - }; - } - - if (filter.description) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'grades', - 'description', - filter.description, - ), - }; - } - - - - - - - if (filter.sort_orderRange) { - const [start, end] = filter.sort_orderRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - sort_order: { - ...where.sort_order, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - sort_order: { - ...where.sort_order, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.grades.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'grades', - 'name', - query, - ), - ], - }; - } - - const records = await db.grades.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/grades.ts b/backend/src/db/api/grades.ts new file mode 100644 index 0000000..4c5b6a8 --- /dev/null +++ b/backend/src/db/api/grades.ts @@ -0,0 +1,282 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Grades } from '@/db/models/grades'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type GradesData = Partial> & { + organization?: string | null; +}; + +interface GradesFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + code?: string; + description?: string; + active?: boolean | string; + organization?: string; + sort_orderRange?: Array; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class GradesDBApi { + static async create( + data: GradesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const grades = await db.grades.create( + { + id: data.id || undefined, + name: data.name || null, + code: data.code || null, + sort_order: data.sort_order || null, + description: data.description || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await grades.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + + return grades; + } + + static async bulkImport( + data: GradesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const gradesData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + code: item.code || null, + sort_order: item.sort_order || null, + description: item.description || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.grades.bulkCreate(gradesData, { transaction }); + } + + static async update( + id: string, + data: GradesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const grades = await db.grades.findByPk(id, { transaction }); + + if (!grades) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.code !== undefined) updatePayload.code = data.code; + if (data.sort_order !== undefined) + updatePayload.sort_order = data.sort_order; + if (data.description !== undefined) + updatePayload.description = data.description; + + updatePayload.updatedById = currentUser.id; + + await grades.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await grades.setOrganization(orgId ?? undefined, { transaction }); + } + + return grades; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.grades, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.grades, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const grades = await db.grades.findOne({ where, transaction }); + + if (!grades) { + return null; + } + + const output: Record = grades.get({ plain: true }); + + const [classes_grade, fee_plans_grade, organization] = await Promise.all([ + grades.getClasses_grade({ transaction }), + grades.getFee_plans_grade({ transaction }), + grades.getOrganization({ transaction }), + ]); + output.classes_grade = classes_grade; + output.fee_plans_grade = fee_plans_grade; + output.organization = organization; + + return output; + } + + static async findAll( + filter: GradesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Grades[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { ...where, [Op.and]: Utils.ilike('grades', 'name', filter.name) }; + } + if (filter.code) { + where = { ...where, [Op.and]: Utils.ilike('grades', 'code', filter.code) }; + } + if (filter.description) { + where = { + ...where, + [Op.and]: Utils.ilike('grades', 'description', filter.description), + }; + } + if (filter.sort_orderRange) { + const [start, end] = filter.sort_orderRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, sort_order: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + sort_order: { + ...(typeof where.sort_order === 'object' ? where.sort_order : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.grades.findAndCountAll({ + where, + include: [{ model: db.organizations, as: 'organization' }], + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.grades, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default GradesDBApi; diff --git a/backend/src/db/api/guardians.js b/backend/src/db/api/guardians.js deleted file mode 100644 index 3d67e91..0000000 --- a/backend/src/db/api/guardians.js +++ /dev/null @@ -1,557 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class GuardiansDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const guardians = await db.guardians.create( - { - id: data.id || undefined, - - full_name: data.full_name - || - null - , - - relationship: data.relationship - || - null - , - - phone: data.phone - || - null - , - - email: data.email - || - null - , - - address: data.address - || - null - , - - primary_contact: data.primary_contact - || - false - - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await guardians.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await guardians.setStudent( data.student || null, { - transaction, - }); - - - - - - - return guardians; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const guardiansData = data.map((item, index) => ({ - id: item.id || undefined, - - full_name: item.full_name - || - null - , - - relationship: item.relationship - || - null - , - - phone: item.phone - || - null - , - - email: item.email - || - null - , - - address: item.address - || - null - , - - primary_contact: item.primary_contact - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const guardians = await db.guardians.bulkCreate(guardiansData, { transaction }); - - // For each item created, replace relation files - - - return guardians; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const guardians = await db.guardians.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.full_name !== undefined) updatePayload.full_name = data.full_name; - - - if (data.relationship !== undefined) updatePayload.relationship = data.relationship; - - - if (data.phone !== undefined) updatePayload.phone = data.phone; - - - if (data.email !== undefined) updatePayload.email = data.email; - - - if (data.address !== undefined) updatePayload.address = data.address; - - - if (data.primary_contact !== undefined) updatePayload.primary_contact = data.primary_contact; - - - updatePayload.updatedById = currentUser.id; - - await guardians.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await guardians.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.student !== undefined) { - await guardians.setStudent( - - data.student, - - { transaction } - ); - } - - - - - - - - return guardians; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const guardians = await db.guardians.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of guardians) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of guardians) { - await record.destroy({transaction}); - } - }); - - - return guardians; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const guardians = await db.guardians.findByPk(id, options); - - await guardians.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await guardians.destroy({ - transaction - }); - - return guardians; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const guardians = await db.guardians.findOne( - { where }, - { transaction }, - ); - - if (!guardians) { - return guardians; - } - - const output = guardians.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await guardians.getOrganization({ - transaction - }); - - - output.student = await guardians.getStudent({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.students, - as: 'student', - - where: filter.student ? { - [Op.or]: [ - { id: { [Op.in]: filter.student.split('|').map(term => Utils.uuid(term)) } }, - { - student_number: { - [Op.or]: filter.student.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.full_name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'guardians', - 'full_name', - filter.full_name, - ), - }; - } - - if (filter.phone) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'guardians', - 'phone', - filter.phone, - ), - }; - } - - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'guardians', - 'email', - filter.email, - ), - }; - } - - if (filter.address) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'guardians', - 'address', - filter.address, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.relationship) { - where = { - ...where, - relationship: filter.relationship, - }; - } - - if (filter.primary_contact) { - where = { - ...where, - primary_contact: filter.primary_contact, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.guardians.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'guardians', - 'full_name', - query, - ), - ], - }; - } - - const records = await db.guardians.findAll({ - attributes: [ 'id', 'full_name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['full_name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.full_name, - })); - } - - -}; - diff --git a/backend/src/db/api/guardians.ts b/backend/src/db/api/guardians.ts new file mode 100644 index 0000000..28a50e7 --- /dev/null +++ b/backend/src/db/api/guardians.ts @@ -0,0 +1,324 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Guardians } from '@/db/models/guardians'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type GuardiansData = Partial> & { + organization?: string | null; + student?: string | null; +}; + +interface GuardiansFilter { + limit?: number | string; + page?: number | string; + id?: string; + full_name?: string; + phone?: string; + email?: string; + address?: string; + active?: boolean | string; + relationship?: string; + primary_contact?: boolean | string; + student?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class GuardiansDBApi { + static async create( + data: GuardiansData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const guardians = await db.guardians.create( + { + id: data.id || undefined, + full_name: data.full_name || null, + relationship: data.relationship || null, + phone: data.phone || null, + email: data.email || null, + address: data.address || null, + primary_contact: data.primary_contact || false, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await guardians.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await guardians.setStudent(data.student ?? undefined, { transaction }); + + return guardians; + } + + static async bulkImport( + data: GuardiansData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const guardiansData = data.map((item, index) => ({ + id: item.id || undefined, + full_name: item.full_name || null, + relationship: item.relationship || null, + phone: item.phone || null, + email: item.email || null, + address: item.address || null, + primary_contact: item.primary_contact || false, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.guardians.bulkCreate(guardiansData, { transaction }); + } + + static async update( + id: string, + data: GuardiansData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const guardians = await db.guardians.findByPk(id, { transaction }); + + if (!guardians) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.full_name !== undefined) updatePayload.full_name = data.full_name; + if (data.relationship !== undefined) + updatePayload.relationship = data.relationship; + if (data.phone !== undefined) updatePayload.phone = data.phone; + if (data.email !== undefined) updatePayload.email = data.email; + if (data.address !== undefined) updatePayload.address = data.address; + if (data.primary_contact !== undefined) + updatePayload.primary_contact = data.primary_contact; + + updatePayload.updatedById = currentUser.id; + + await guardians.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await guardians.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.student !== undefined) { + await guardians.setStudent(data.student ?? undefined, { transaction }); + } + + return guardians; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.guardians, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.guardians, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const guardians = await db.guardians.findOne({ where, transaction }); + + if (!guardians) { + return null; + } + + const output: Record = guardians.get({ plain: true }); + + const [organization, student] = await Promise.all([ + guardians.getOrganization({ transaction }), + guardians.getStudent({ transaction }), + ]); + output.organization = organization; + output.student = student; + + return output; + } + + static async findAll( + filter: GuardiansFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Guardians[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.students, + as: 'student', + where: filter.student + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + student_number: { + [Op.or]: filter.student + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.full_name) { + where = { + ...where, + [Op.and]: Utils.ilike('guardians', 'full_name', filter.full_name), + }; + } + if (filter.phone) { + where = { + ...where, + [Op.and]: Utils.ilike('guardians', 'phone', filter.phone), + }; + } + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('guardians', 'email', filter.email), + }; + } + if (filter.address) { + where = { + ...where, + [Op.and]: Utils.ilike('guardians', 'address', filter.address), + }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.relationship) { + where = { ...where, relationship: filter.relationship }; + } + if (filter.primary_contact) { + where = { ...where, primary_contact: filter.primary_contact }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.guardians.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.guardians, + 'full_name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default GuardiansDBApi; diff --git a/backend/src/db/api/invoices.js b/backend/src/db/api/invoices.js deleted file mode 100644 index efdbacf..0000000 --- a/backend/src/db/api/invoices.js +++ /dev/null @@ -1,866 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class InvoicesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const invoices = await db.invoices.create( - { - id: data.id || undefined, - - invoice_number: data.invoice_number - || - null - , - - issue_date: data.issue_date - || - null - , - - due_date: data.due_date - || - null - , - - subtotal: data.subtotal - || - null - , - - discount_amount: data.discount_amount - || - null - , - - tax_amount: data.tax_amount - || - null - , - - total_amount: data.total_amount - || - null - , - - balance_due: data.balance_due - || - null - , - - status: data.status - || - null - , - - notes: data.notes - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await invoices.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await invoices.setCampus( data.campus || null, { - transaction, - }); - - await invoices.setStudent( data.student || null, { - transaction, - }); - - await invoices.setFee_plan( data.fee_plan || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.invoices.getTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices.id, - }, - data.attachments, - options, - ); - - - return invoices; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const invoicesData = data.map((item, index) => ({ - id: item.id || undefined, - - invoice_number: item.invoice_number - || - null - , - - issue_date: item.issue_date - || - null - , - - due_date: item.due_date - || - null - , - - subtotal: item.subtotal - || - null - , - - discount_amount: item.discount_amount - || - null - , - - tax_amount: item.tax_amount - || - null - , - - total_amount: item.total_amount - || - null - , - - balance_due: item.balance_due - || - null - , - - status: item.status - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const invoices = await db.invoices.bulkCreate(invoicesData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < invoices.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.invoices.getTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices[i].id, - }, - data[i].attachments, - options, - ); - } - - - return invoices; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const invoices = await db.invoices.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.invoice_number !== undefined) updatePayload.invoice_number = data.invoice_number; - - - if (data.issue_date !== undefined) updatePayload.issue_date = data.issue_date; - - - if (data.due_date !== undefined) updatePayload.due_date = data.due_date; - - - if (data.subtotal !== undefined) updatePayload.subtotal = data.subtotal; - - - if (data.discount_amount !== undefined) updatePayload.discount_amount = data.discount_amount; - - - if (data.tax_amount !== undefined) updatePayload.tax_amount = data.tax_amount; - - - if (data.total_amount !== undefined) updatePayload.total_amount = data.total_amount; - - - if (data.balance_due !== undefined) updatePayload.balance_due = data.balance_due; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - if (data.notes !== undefined) updatePayload.notes = data.notes; - - - updatePayload.updatedById = currentUser.id; - - await invoices.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await invoices.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await invoices.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.student !== undefined) { - await invoices.setStudent( - - data.student, - - { transaction } - ); - } - - if (data.fee_plan !== undefined) { - await invoices.setFee_plan( - - data.fee_plan, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.invoices.getTableName(), - belongsToColumn: 'attachments', - belongsToId: invoices.id, - }, - data.attachments, - options, - ); - - - return invoices; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const invoices = await db.invoices.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of invoices) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of invoices) { - await record.destroy({transaction}); - } - }); - - - return invoices; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const invoices = await db.invoices.findByPk(id, options); - - await invoices.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await invoices.destroy({ - transaction - }); - - return invoices; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const invoices = await db.invoices.findOne( - { where }, - { transaction }, - ); - - if (!invoices) { - return invoices; - } - - const output = invoices.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - output.payments_invoice = await invoices.getPayments_invoice({ - transaction - }); - - - - - - - - - output.organization = await invoices.getOrganization({ - transaction - }); - - - output.campus = await invoices.getCampus({ - transaction - }); - - - output.student = await invoices.getStudent({ - transaction - }); - - - output.fee_plan = await invoices.getFee_plan({ - transaction - }); - - - output.attachments = await invoices.getAttachments({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.students, - as: 'student', - - where: filter.student ? { - [Op.or]: [ - { id: { [Op.in]: filter.student.split('|').map(term => Utils.uuid(term)) } }, - { - student_number: { - [Op.or]: filter.student.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.fee_plans, - as: 'fee_plan', - - where: filter.fee_plan ? { - [Op.or]: [ - { id: { [Op.in]: filter.fee_plan.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.fee_plan.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'attachments', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.invoice_number) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'invoices', - 'invoice_number', - filter.invoice_number, - ), - }; - } - - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'invoices', - 'notes', - filter.notes, - ), - }; - } - - - - - - - if (filter.issue_dateRange) { - const [start, end] = filter.issue_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - issue_date: { - ...where.issue_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - issue_date: { - ...where.issue_date, - [Op.lte]: end, - }, - }; - } - } - - if (filter.due_dateRange) { - const [start, end] = filter.due_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - due_date: { - ...where.due_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - due_date: { - ...where.due_date, - [Op.lte]: end, - }, - }; - } - } - - if (filter.subtotalRange) { - const [start, end] = filter.subtotalRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - subtotal: { - ...where.subtotal, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - subtotal: { - ...where.subtotal, - [Op.lte]: end, - }, - }; - } - } - - if (filter.discount_amountRange) { - const [start, end] = filter.discount_amountRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - discount_amount: { - ...where.discount_amount, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - discount_amount: { - ...where.discount_amount, - [Op.lte]: end, - }, - }; - } - } - - if (filter.tax_amountRange) { - const [start, end] = filter.tax_amountRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - tax_amount: { - ...where.tax_amount, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - tax_amount: { - ...where.tax_amount, - [Op.lte]: end, - }, - }; - } - } - - if (filter.total_amountRange) { - const [start, end] = filter.total_amountRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - total_amount: { - ...where.total_amount, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - total_amount: { - ...where.total_amount, - [Op.lte]: end, - }, - }; - } - } - - if (filter.balance_dueRange) { - const [start, end] = filter.balance_dueRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - balance_due: { - ...where.balance_due, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - balance_due: { - ...where.balance_due, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.invoices.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'invoices', - 'invoice_number', - query, - ), - ], - }; - } - - const records = await db.invoices.findAll({ - attributes: [ 'id', 'invoice_number' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['invoice_number', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.invoice_number, - })); - } - - -}; - diff --git a/backend/src/db/api/invoices.ts b/backend/src/db/api/invoices.ts new file mode 100644 index 0000000..3100e8d --- /dev/null +++ b/backend/src/db/api/invoices.ts @@ -0,0 +1,467 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Invoices } from '@/db/models/invoices'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type InvoicesData = Partial> & { + organization?: string | null; + campus?: string | null; + student?: string | null; + fee_plan?: string | null; + attachments?: FileInput | FileInput[] | null; +}; + +type NumberRange = Array; +type DateRange = Array; + +interface InvoicesFilter { + limit?: number | string; + page?: number | string; + id?: string; + invoice_number?: string; + notes?: string; + issue_dateRange?: DateRange; + due_dateRange?: DateRange; + subtotalRange?: NumberRange; + discount_amountRange?: NumberRange; + tax_amountRange?: NumberRange; + total_amountRange?: NumberRange; + balance_dueRange?: NumberRange; + active?: boolean | string; + status?: string; + campus?: string; + student?: string; + fee_plan?: string; + organization?: string; + createdAtRange?: DateRange; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function invoicesTableName(): string { + const name = db.invoices.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +/** Apply a `>= / <=` range to a where field, preserving an existing bound. */ +function applyRange( + where: WhereAttributeHash, + field: string, + range: NumberRange | DateRange | undefined, +): WhereAttributeHash { + if (!range) return where; + const [start, end] = range; + let next = where; + if (start !== undefined && start !== null && start !== '') { + next = { ...next, [field]: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + const existing = next[field]; + next = { + ...next, + [field]: { + ...(typeof existing === 'object' && existing !== null ? existing : {}), + [Op.lte]: end, + }, + }; + } + return next; +} + +class InvoicesDBApi { + static async create( + data: InvoicesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const invoices = await db.invoices.create( + { + id: data.id || undefined, + invoice_number: data.invoice_number || null, + issue_date: data.issue_date || null, + due_date: data.due_date || null, + subtotal: data.subtotal || null, + discount_amount: data.discount_amount || null, + tax_amount: data.tax_amount || null, + total_amount: data.total_amount || null, + balance_due: data.balance_due || null, + status: data.status || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await invoices.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await invoices.setCampus(data.campus ?? undefined, { transaction }); + await invoices.setStudent(data.student ?? undefined, { transaction }); + await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: invoicesTableName(), + belongsToColumn: 'attachments', + belongsToId: invoices.id, + }, + data.attachments, + options, + ); + + return invoices; + } + + static async bulkImport( + data: InvoicesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const invoicesData = data.map((item, index) => ({ + id: item.id || undefined, + invoice_number: item.invoice_number || null, + issue_date: item.issue_date || null, + due_date: item.due_date || null, + subtotal: item.subtotal || null, + discount_amount: item.discount_amount || null, + tax_amount: item.tax_amount || null, + total_amount: item.total_amount || null, + balance_due: item.balance_due || null, + status: item.status || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const invoices = await db.invoices.bulkCreate(invoicesData, { transaction }); + + for (let i = 0; i < invoices.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: invoicesTableName(), + belongsToColumn: 'attachments', + belongsToId: invoices[i].id, + }, + data[i].attachments, + options, + ); + } + + return invoices; + } + + static async update( + id: string, + data: InvoicesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const invoices = await db.invoices.findByPk(id, { transaction }); + + if (!invoices) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.invoice_number !== undefined) + updatePayload.invoice_number = data.invoice_number; + if (data.issue_date !== undefined) + updatePayload.issue_date = data.issue_date; + if (data.due_date !== undefined) updatePayload.due_date = data.due_date; + if (data.subtotal !== undefined) updatePayload.subtotal = data.subtotal; + if (data.discount_amount !== undefined) + updatePayload.discount_amount = data.discount_amount; + if (data.tax_amount !== undefined) + updatePayload.tax_amount = data.tax_amount; + if (data.total_amount !== undefined) + updatePayload.total_amount = data.total_amount; + if (data.balance_due !== undefined) + updatePayload.balance_due = data.balance_due; + if (data.status !== undefined) updatePayload.status = data.status; + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await invoices.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await invoices.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await invoices.setCampus(data.campus ?? undefined, { transaction }); + } + if (data.student !== undefined) { + await invoices.setStudent(data.student ?? undefined, { transaction }); + } + if (data.fee_plan !== undefined) { + await invoices.setFee_plan(data.fee_plan ?? undefined, { transaction }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: invoicesTableName(), + belongsToColumn: 'attachments', + belongsToId: invoices.id, + }, + data.attachments, + options, + ); + + return invoices; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.invoices, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.invoices, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const invoices = await db.invoices.findOne({ where, transaction }); + + if (!invoices) { + return null; + } + + const output: Record = invoices.get({ plain: true }); + + const [ + payments_invoice, + organization, + campus, + student, + fee_plan, + attachments, + ] = await Promise.all([ + invoices.getPayments_invoice({ transaction }), + invoices.getOrganization({ transaction }), + invoices.getCampus({ transaction }), + invoices.getStudent({ transaction }), + invoices.getFee_plan({ transaction }), + invoices.getAttachments({ transaction }), + ]); + output.payments_invoice = payments_invoice; + output.organization = organization; + output.campus = campus; + output.student = student; + output.fee_plan = fee_plan; + output.attachments = attachments; + + return output; + } + + static async findAll( + filter: InvoicesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Invoices[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.students, + as: 'student', + where: filter.student + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.student.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + student_number: { + [Op.or]: filter.student + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.fee_plans, + as: 'fee_plan', + where: filter.fee_plan + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.fee_plan + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.fee_plan + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'attachments' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.invoice_number) { + where = { + ...where, + [Op.and]: Utils.ilike('invoices', 'invoice_number', filter.invoice_number), + }; + } + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('invoices', 'notes', filter.notes), + }; + } + + where = applyRange(where, 'issue_date', filter.issue_dateRange); + where = applyRange(where, 'due_date', filter.due_dateRange); + where = applyRange(where, 'subtotal', filter.subtotalRange); + where = applyRange(where, 'discount_amount', filter.discount_amountRange); + where = applyRange(where, 'tax_amount', filter.tax_amountRange); + where = applyRange(where, 'total_amount', filter.total_amountRange); + where = applyRange(where, 'balance_due', filter.balance_dueRange); + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + where = applyRange(where, 'createdAt', filter.createdAtRange); + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.invoices.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.invoices, + 'invoice_number', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default InvoicesDBApi; diff --git a/backend/src/db/api/message_recipients.js b/backend/src/db/api/message_recipients.js deleted file mode 100644 index 1172071..0000000 --- a/backend/src/db/api/message_recipients.js +++ /dev/null @@ -1,581 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Message_recipientsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const message_recipients = await db.message_recipients.create( - { - id: data.id || undefined, - - recipient_type: data.recipient_type - || - null - , - - recipient_label: data.recipient_label - || - null - , - - destination: data.destination - || - null - , - - delivery_status: data.delivery_status - || - null - , - - delivered_at: data.delivered_at - || - null - , - - read_at: data.read_at - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await message_recipients.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await message_recipients.setMessage( data.message || null, { - transaction, - }); - - - - - - - return message_recipients; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const message_recipientsData = data.map((item, index) => ({ - id: item.id || undefined, - - recipient_type: item.recipient_type - || - null - , - - recipient_label: item.recipient_label - || - null - , - - destination: item.destination - || - null - , - - delivery_status: item.delivery_status - || - null - , - - delivered_at: item.delivered_at - || - null - , - - read_at: item.read_at - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const message_recipients = await db.message_recipients.bulkCreate(message_recipientsData, { transaction }); - - // For each item created, replace relation files - - - return message_recipients; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const message_recipients = await db.message_recipients.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.recipient_type !== undefined) updatePayload.recipient_type = data.recipient_type; - - - if (data.recipient_label !== undefined) updatePayload.recipient_label = data.recipient_label; - - - if (data.destination !== undefined) updatePayload.destination = data.destination; - - - if (data.delivery_status !== undefined) updatePayload.delivery_status = data.delivery_status; - - - if (data.delivered_at !== undefined) updatePayload.delivered_at = data.delivered_at; - - - if (data.read_at !== undefined) updatePayload.read_at = data.read_at; - - - updatePayload.updatedById = currentUser.id; - - await message_recipients.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await message_recipients.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.message !== undefined) { - await message_recipients.setMessage( - - data.message, - - { transaction } - ); - } - - - - - - - - return message_recipients; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const message_recipients = await db.message_recipients.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of message_recipients) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of message_recipients) { - await record.destroy({transaction}); - } - }); - - - return message_recipients; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const message_recipients = await db.message_recipients.findByPk(id, options); - - await message_recipients.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await message_recipients.destroy({ - transaction - }); - - return message_recipients; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const message_recipients = await db.message_recipients.findOne( - { where }, - { transaction }, - ); - - if (!message_recipients) { - return message_recipients; - } - - const output = message_recipients.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await message_recipients.getOrganization({ - transaction - }); - - - output.message = await message_recipients.getMessage({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.messages, - as: 'message', - - where: filter.message ? { - [Op.or]: [ - { id: { [Op.in]: filter.message.split('|').map(term => Utils.uuid(term)) } }, - { - subject: { - [Op.or]: filter.message.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.recipient_label) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'message_recipients', - 'recipient_label', - filter.recipient_label, - ), - }; - } - - if (filter.destination) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'message_recipients', - 'destination', - filter.destination, - ), - }; - } - - - - - - - if (filter.delivered_atRange) { - const [start, end] = filter.delivered_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - delivered_at: { - ...where.delivered_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - delivered_at: { - ...where.delivered_at, - [Op.lte]: end, - }, - }; - } - } - - if (filter.read_atRange) { - const [start, end] = filter.read_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - read_at: { - ...where.read_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - read_at: { - ...where.read_at, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.recipient_type) { - where = { - ...where, - recipient_type: filter.recipient_type, - }; - } - - if (filter.delivery_status) { - where = { - ...where, - delivery_status: filter.delivery_status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.message_recipients.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'message_recipients', - 'recipient_label', - query, - ), - ], - }; - } - - const records = await db.message_recipients.findAll({ - attributes: [ 'id', 'recipient_label' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['recipient_label', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.recipient_label, - })); - } - - -}; - diff --git a/backend/src/db/api/message_recipients.ts b/backend/src/db/api/message_recipients.ts new file mode 100644 index 0000000..caa1e23 --- /dev/null +++ b/backend/src/db/api/message_recipients.ts @@ -0,0 +1,373 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { MessageRecipients } from '@/db/models/message_recipients'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type MessageRecipientsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + message?: string | null; +}; + +interface MessageRecipientsFilter { + limit?: number | string; + page?: number | string; + id?: string; + recipient_label?: string; + destination?: string; + delivered_atRange?: Array; + read_atRange?: Array; + active?: boolean | string; + recipient_type?: string; + delivery_status?: string; + message?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Message_recipientsDBApi { + static async create( + data: MessageRecipientsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const message_recipients = await db.message_recipients.create( + { + id: data.id || undefined, + recipient_type: data.recipient_type || null, + recipient_label: data.recipient_label || null, + destination: data.destination || null, + delivery_status: data.delivery_status || null, + delivered_at: data.delivered_at || null, + read_at: data.read_at || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await message_recipients.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await message_recipients.setMessage(data.message ?? undefined, { + transaction, + }); + + return message_recipients; + } + + static async bulkImport( + data: MessageRecipientsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const message_recipientsData = data.map((item, index) => ({ + id: item.id || undefined, + recipient_type: item.recipient_type || null, + recipient_label: item.recipient_label || null, + destination: item.destination || null, + delivery_status: item.delivery_status || null, + delivered_at: item.delivered_at || null, + read_at: item.read_at || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.message_recipients.bulkCreate(message_recipientsData, { + transaction, + }); + } + + static async update( + id: string, + data: MessageRecipientsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const message_recipients = await db.message_recipients.findByPk(id, { + transaction, + }); + + if (!message_recipients) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.recipient_type !== undefined) + updatePayload.recipient_type = data.recipient_type; + if (data.recipient_label !== undefined) + updatePayload.recipient_label = data.recipient_label; + if (data.destination !== undefined) + updatePayload.destination = data.destination; + if (data.delivery_status !== undefined) + updatePayload.delivery_status = data.delivery_status; + if (data.delivered_at !== undefined) + updatePayload.delivered_at = data.delivered_at; + if (data.read_at !== undefined) updatePayload.read_at = data.read_at; + + updatePayload.updatedById = currentUser.id; + + await message_recipients.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await message_recipients.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.message !== undefined) { + await message_recipients.setMessage(data.message ?? undefined, { + transaction, + }); + } + + return message_recipients; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.message_recipients, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.message_recipients, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const message_recipients = await db.message_recipients.findOne({ + where, + transaction, + }); + + if (!message_recipients) { + return null; + } + + const output: Record = message_recipients.get({ + plain: true, + }); + + const [organization, message] = await Promise.all([ + message_recipients.getOrganization({ transaction }), + message_recipients.getMessage({ transaction }), + ]); + output.organization = organization; + output.message = message; + + return output; + } + + static async findAll( + filter: MessageRecipientsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: MessageRecipients[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.messages, + as: 'message', + where: filter.message + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.message.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + subject: { + [Op.or]: filter.message + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.recipient_label) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'message_recipients', + 'recipient_label', + filter.recipient_label, + ), + }; + } + if (filter.destination) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'message_recipients', + 'destination', + filter.destination, + ), + }; + } + if (filter.delivered_atRange) { + const [start, end] = filter.delivered_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, delivered_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + delivered_at: { + ...(typeof where.delivered_at === 'object' + ? where.delivered_at + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.read_atRange) { + const [start, end] = filter.read_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, read_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + read_at: { + ...(typeof where.read_at === 'object' ? where.read_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.recipient_type) { + where = { ...where, recipient_type: filter.recipient_type }; + } + if (filter.delivery_status) { + where = { ...where, delivery_status: filter.delivery_status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.message_recipients.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.message_recipients, + 'recipient_label', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Message_recipientsDBApi; diff --git a/backend/src/db/api/messages.js b/backend/src/db/api/messages.js deleted file mode 100644 index 5fab895..0000000 --- a/backend/src/db/api/messages.js +++ /dev/null @@ -1,647 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class MessagesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.create( - { - id: data.id || undefined, - - subject: data.subject - || - null - , - - body: data.body - || - null - , - - channel: data.channel - || - null - , - - audience: data.audience - || - null - , - - sent_at: data.sent_at - || - null - , - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await messages.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await messages.setCampus( data.campus || null, { - transaction, - }); - - await messages.setSent_by( data.sent_by || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.messages.getTableName(), - belongsToColumn: 'attachments', - belongsToId: messages.id, - }, - data.attachments, - options, - ); - - - return messages; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const messagesData = data.map((item, index) => ({ - id: item.id || undefined, - - subject: item.subject - || - null - , - - body: item.body - || - null - , - - channel: item.channel - || - null - , - - audience: item.audience - || - null - , - - sent_at: item.sent_at - || - null - , - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const messages = await db.messages.bulkCreate(messagesData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < messages.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.messages.getTableName(), - belongsToColumn: 'attachments', - belongsToId: messages[i].id, - }, - data[i].attachments, - options, - ); - } - - - return messages; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const messages = await db.messages.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.subject !== undefined) updatePayload.subject = data.subject; - - - if (data.body !== undefined) updatePayload.body = data.body; - - - if (data.channel !== undefined) updatePayload.channel = data.channel; - - - if (data.audience !== undefined) updatePayload.audience = data.audience; - - - if (data.sent_at !== undefined) updatePayload.sent_at = data.sent_at; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await messages.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await messages.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await messages.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.sent_by !== undefined) { - await messages.setSent_by( - - data.sent_by, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.messages.getTableName(), - belongsToColumn: 'attachments', - belongsToId: messages.id, - }, - data.attachments, - options, - ); - - - return messages; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of messages) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of messages) { - await record.destroy({transaction}); - } - }); - - - return messages; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findByPk(id, options); - - await messages.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await messages.destroy({ - transaction - }); - - return messages; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const messages = await db.messages.findOne( - { where }, - { transaction }, - ); - - if (!messages) { - return messages; - } - - const output = messages.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - output.message_recipients_message = await messages.getMessage_recipients_message({ - transaction - }); - - - - - output.organization = await messages.getOrganization({ - transaction - }); - - - output.campus = await messages.getCampus({ - transaction - }); - - - output.sent_by = await messages.getSent_by({ - transaction - }); - - - output.attachments = await messages.getAttachments({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.users, - as: 'sent_by', - - where: filter.sent_by ? { - [Op.or]: [ - { id: { [Op.in]: filter.sent_by.split('|').map(term => Utils.uuid(term)) } }, - { - firstName: { - [Op.or]: filter.sent_by.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'attachments', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.subject) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'messages', - 'subject', - filter.subject, - ), - }; - } - - if (filter.body) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'messages', - 'body', - filter.body, - ), - }; - } - - - - - - - if (filter.sent_atRange) { - const [start, end] = filter.sent_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - sent_at: { - ...where.sent_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - sent_at: { - ...where.sent_at, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.channel) { - where = { - ...where, - channel: filter.channel, - }; - } - - if (filter.audience) { - where = { - ...where, - audience: filter.audience, - }; - } - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.messages.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'messages', - 'subject', - query, - ), - ], - }; - } - - const records = await db.messages.findAll({ - attributes: [ 'id', 'subject' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['subject', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.subject, - })); - } - - -}; - diff --git a/backend/src/db/api/messages.ts b/backend/src/db/api/messages.ts new file mode 100644 index 0000000..77b90b9 --- /dev/null +++ b/backend/src/db/api/messages.ts @@ -0,0 +1,410 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Messages } from '@/db/models/messages'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type MessagesData = Partial> & { + organization?: string | null; + campus?: string | null; + sent_by?: string | null; + attachments?: FileInput | FileInput[] | null; +}; + +interface MessagesFilter { + limit?: number | string; + page?: number | string; + id?: string; + subject?: string; + body?: string; + sent_atRange?: Array; + active?: boolean | string; + channel?: string; + audience?: string; + status?: string; + campus?: string; + sent_by?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function messagesTableName(): string { + const name = db.messages.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class MessagesDBApi { + static async create( + data: MessagesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const messages = await db.messages.create( + { + id: data.id || undefined, + subject: data.subject || null, + body: data.body || null, + channel: data.channel || null, + audience: data.audience || null, + sent_at: data.sent_at || null, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await messages.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await messages.setCampus(data.campus ?? undefined, { transaction }); + await messages.setSent_by(data.sent_by ?? undefined, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: messagesTableName(), + belongsToColumn: 'attachments', + belongsToId: messages.id, + }, + data.attachments, + options, + ); + + return messages; + } + + static async bulkImport( + data: MessagesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const messagesData = data.map((item, index) => ({ + id: item.id || undefined, + subject: item.subject || null, + body: item.body || null, + channel: item.channel || null, + audience: item.audience || null, + sent_at: item.sent_at || null, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const messages = await db.messages.bulkCreate(messagesData, { transaction }); + + for (let i = 0; i < messages.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: messagesTableName(), + belongsToColumn: 'attachments', + belongsToId: messages[i].id, + }, + data[i].attachments, + options, + ); + } + + return messages; + } + + static async update( + id: string, + data: MessagesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const messages = await db.messages.findByPk(id, { transaction }); + + if (!messages) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.subject !== undefined) updatePayload.subject = data.subject; + if (data.body !== undefined) updatePayload.body = data.body; + if (data.channel !== undefined) updatePayload.channel = data.channel; + if (data.audience !== undefined) updatePayload.audience = data.audience; + if (data.sent_at !== undefined) updatePayload.sent_at = data.sent_at; + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await messages.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await messages.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await messages.setCampus(data.campus ?? undefined, { transaction }); + } + if (data.sent_by !== undefined) { + await messages.setSent_by(data.sent_by ?? undefined, { transaction }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: messagesTableName(), + belongsToColumn: 'attachments', + belongsToId: messages.id, + }, + data.attachments, + options, + ); + + return messages; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.messages, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.messages, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const messages = await db.messages.findOne({ where, transaction }); + + if (!messages) { + return null; + } + + const output: Record = messages.get({ plain: true }); + + const [ + message_recipients_message, + organization, + campus, + sent_by, + attachments, + ] = await Promise.all([ + messages.getMessage_recipients_message({ transaction }), + messages.getOrganization({ transaction }), + messages.getCampus({ transaction }), + messages.getSent_by({ transaction }), + messages.getAttachments({ transaction }), + ]); + output.message_recipients_message = message_recipients_message; + output.organization = organization; + output.campus = campus; + output.sent_by = sent_by; + output.attachments = attachments; + + return output; + } + + static async findAll( + filter: MessagesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Messages[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.users, + as: 'sent_by', + where: filter.sent_by + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.sent_by.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + firstName: { + [Op.or]: filter.sent_by + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'attachments' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.subject) { + where = { + ...where, + [Op.and]: Utils.ilike('messages', 'subject', filter.subject), + }; + } + if (filter.body) { + where = { + ...where, + [Op.and]: Utils.ilike('messages', 'body', filter.body), + }; + } + if (filter.sent_atRange) { + const [start, end] = filter.sent_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, sent_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + sent_at: { + ...(typeof where.sent_at === 'object' ? where.sent_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.channel) { + where = { ...where, channel: filter.channel }; + } + if (filter.audience) { + where = { ...where, audience: filter.audience }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.messages.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.messages, + 'subject', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default MessagesDBApi; diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js deleted file mode 100644 index ea66e38..0000000 --- a/backend/src/db/api/organizations.js +++ /dev/null @@ -1,461 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class OrganizationsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const organizations = await db.organizations.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - - - - - - return organizations; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const organizationsData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const organizations = await db.organizations.bulkCreate(organizationsData, { transaction }); - - // For each item created, replace relation files - - - return organizations; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const organizations = await db.organizations.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - updatePayload.updatedById = currentUser.id; - - await organizations.update(updatePayload, {transaction}); - - - - - - - - - - return organizations; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const organizations = await db.organizations.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of organizations) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of organizations) { - await record.destroy({transaction}); - } - }); - - - return organizations; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const organizations = await db.organizations.findByPk(id, options); - - await organizations.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await organizations.destroy({ - transaction - }); - - return organizations; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const organizations = await db.organizations.findOne( - { where }, - { transaction }, - ); - - if (!organizations) { - return organizations; - } - - const output = organizations.get({plain: true}); - - - output.users_organizations = await organizations.getUsers_organizations({ - transaction - }); - - - - - - output.campuses_organization = await organizations.getCampuses_organization({ - transaction - }); - - - output.academic_years_organization = await organizations.getAcademic_years_organization({ - transaction - }); - - - output.grades_organization = await organizations.getGrades_organization({ - transaction - }); - - - output.subjects_organization = await organizations.getSubjects_organization({ - transaction - }); - - - output.students_organization = await organizations.getStudents_organization({ - transaction - }); - - - output.guardians_organization = await organizations.getGuardians_organization({ - transaction - }); - - - output.staff_organization = await organizations.getStaff_organization({ - transaction - }); - - - output.classes_organization = await organizations.getClasses_organization({ - transaction - }); - - - output.class_enrollments_organization = await organizations.getClass_enrollments_organization({ - transaction - }); - - - output.class_subjects_organization = await organizations.getClass_subjects_organization({ - transaction - }); - - - output.timetables_organization = await organizations.getTimetables_organization({ - transaction - }); - - - output.timetable_periods_organization = await organizations.getTimetable_periods_organization({ - transaction - }); - - - output.attendance_sessions_organization = await organizations.getAttendance_sessions_organization({ - transaction - }); - - - output.attendance_records_organization = await organizations.getAttendance_records_organization({ - transaction - }); - - - output.fee_plans_organization = await organizations.getFee_plans_organization({ - transaction - }); - - - output.invoices_organization = await organizations.getInvoices_organization({ - transaction - }); - - - output.payments_organization = await organizations.getPayments_organization({ - transaction - }); - - - output.assessments_organization = await organizations.getAssessments_organization({ - transaction - }); - - - output.assessment_results_organization = await organizations.getAssessment_results_organization({ - transaction - }); - - - output.messages_organization = await organizations.getMessages_organization({ - transaction - }); - - - output.message_recipients_organization = await organizations.getMessage_recipients_organization({ - transaction - }); - - - output.documents_organization = await organizations.getDocuments_organization({ - transaction - }); - - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'organizations', - 'name', - filter.name, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.organizations.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'organizations', - 'name', - query, - ), - ], - }; - } - - const records = await db.organizations.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/organizations.ts b/backend/src/db/api/organizations.ts new file mode 100644 index 0000000..d5a99f5 --- /dev/null +++ b/backend/src/db/api/organizations.ts @@ -0,0 +1,287 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Organizations } from '@/db/models/organizations'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type OrganizationsData = Partial>; + +interface OrganizationsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + active?: boolean | string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class OrganizationsDBApi { + static async create( + data: OrganizationsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + return db.organizations.create( + { + id: data.id || undefined, + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + static async bulkImport( + data: OrganizationsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const organizationsData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.organizations.bulkCreate(organizationsData, { transaction }); + } + + static async update( + id: string, + data: OrganizationsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const organizations = await db.organizations.findByPk(id, { transaction }); + + if (!organizations) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await organizations.update(updatePayload, { transaction }); + + return organizations; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.organizations, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.organizations, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const organizations = await db.organizations.findOne({ where, transaction }); + + if (!organizations) { + return null; + } + + const output: Record = organizations.get({ plain: true }); + + const [ + 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, + ] = await Promise.all([ + organizations.getUsers_organizations({ transaction }), + organizations.getCampuses_organization({ transaction }), + organizations.getAcademic_years_organization({ transaction }), + organizations.getGrades_organization({ transaction }), + organizations.getSubjects_organization({ transaction }), + organizations.getStudents_organization({ transaction }), + organizations.getGuardians_organization({ transaction }), + organizations.getStaff_organization({ transaction }), + organizations.getClasses_organization({ transaction }), + organizations.getClass_enrollments_organization({ transaction }), + organizations.getClass_subjects_organization({ transaction }), + organizations.getTimetables_organization({ transaction }), + organizations.getTimetable_periods_organization({ transaction }), + organizations.getAttendance_sessions_organization({ transaction }), + organizations.getAttendance_records_organization({ transaction }), + organizations.getFee_plans_organization({ transaction }), + organizations.getInvoices_organization({ transaction }), + organizations.getPayments_organization({ transaction }), + organizations.getAssessments_organization({ transaction }), + organizations.getAssessment_results_organization({ transaction }), + organizations.getMessages_organization({ transaction }), + organizations.getMessage_recipients_organization({ transaction }), + organizations.getDocuments_organization({ transaction }), + ]); + output.users_organizations = users_organizations; + output.campuses_organization = campuses_organization; + output.academic_years_organization = academic_years_organization; + output.grades_organization = grades_organization; + output.subjects_organization = subjects_organization; + output.students_organization = students_organization; + output.guardians_organization = guardians_organization; + output.staff_organization = staff_organization; + output.classes_organization = classes_organization; + output.class_enrollments_organization = class_enrollments_organization; + output.class_subjects_organization = class_subjects_organization; + output.timetables_organization = timetables_organization; + output.timetable_periods_organization = timetable_periods_organization; + output.attendance_sessions_organization = attendance_sessions_organization; + output.attendance_records_organization = attendance_records_organization; + output.fee_plans_organization = fee_plans_organization; + output.invoices_organization = invoices_organization; + output.payments_organization = payments_organization; + output.assessments_organization = assessments_organization; + output.assessment_results_organization = assessment_results_organization; + output.messages_organization = messages_organization; + output.message_recipients_organization = message_recipients_organization; + output.documents_organization = documents_organization; + + return output; + } + + static async findAll( + filter: OrganizationsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Organizations[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('organizations', 'name', filter.name), + }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.organizations.findAndCountAll({ + where, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.organizations, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default OrganizationsDBApi; diff --git a/backend/src/db/api/payments.js b/backend/src/db/api/payments.js deleted file mode 100644 index e1c6ffe..0000000 --- a/backend/src/db/api/payments.js +++ /dev/null @@ -1,664 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class PaymentsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const payments = await db.payments.create( - { - id: data.id || undefined, - - receipt_number: data.receipt_number - || - null - , - - paid_at: data.paid_at - || - null - , - - amount: data.amount - || - null - , - - method: data.method - || - null - , - - reference_code: data.reference_code - || - null - , - - notes: data.notes - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await payments.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await payments.setInvoice( data.invoice || null, { - transaction, - }); - - await payments.setReceived_by( data.received_by || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.payments.getTableName(), - belongsToColumn: 'proof', - belongsToId: payments.id, - }, - data.proof, - options, - ); - - - return payments; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const paymentsData = data.map((item, index) => ({ - id: item.id || undefined, - - receipt_number: item.receipt_number - || - null - , - - paid_at: item.paid_at - || - null - , - - amount: item.amount - || - null - , - - method: item.method - || - null - , - - reference_code: item.reference_code - || - null - , - - notes: item.notes - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const payments = await db.payments.bulkCreate(paymentsData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < payments.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.payments.getTableName(), - belongsToColumn: 'proof', - belongsToId: payments[i].id, - }, - data[i].proof, - options, - ); - } - - - return payments; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const payments = await db.payments.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.receipt_number !== undefined) updatePayload.receipt_number = data.receipt_number; - - - if (data.paid_at !== undefined) updatePayload.paid_at = data.paid_at; - - - if (data.amount !== undefined) updatePayload.amount = data.amount; - - - if (data.method !== undefined) updatePayload.method = data.method; - - - if (data.reference_code !== undefined) updatePayload.reference_code = data.reference_code; - - - if (data.notes !== undefined) updatePayload.notes = data.notes; - - - updatePayload.updatedById = currentUser.id; - - await payments.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await payments.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.invoice !== undefined) { - await payments.setInvoice( - - data.invoice, - - { transaction } - ); - } - - if (data.received_by !== undefined) { - await payments.setReceived_by( - - data.received_by, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.payments.getTableName(), - belongsToColumn: 'proof', - belongsToId: payments.id, - }, - data.proof, - options, - ); - - - return payments; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const payments = await db.payments.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of payments) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of payments) { - await record.destroy({transaction}); - } - }); - - - return payments; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const payments = await db.payments.findByPk(id, options); - - await payments.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await payments.destroy({ - transaction - }); - - return payments; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const payments = await db.payments.findOne( - { where }, - { transaction }, - ); - - if (!payments) { - return payments; - } - - const output = payments.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await payments.getOrganization({ - transaction - }); - - - output.invoice = await payments.getInvoice({ - transaction - }); - - - output.received_by = await payments.getReceived_by({ - transaction - }); - - - output.proof = await payments.getProof({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.invoices, - as: 'invoice', - - where: filter.invoice ? { - [Op.or]: [ - { id: { [Op.in]: filter.invoice.split('|').map(term => Utils.uuid(term)) } }, - { - invoice_number: { - [Op.or]: filter.invoice.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.staff, - as: 'received_by', - - where: filter.received_by ? { - [Op.or]: [ - { id: { [Op.in]: filter.received_by.split('|').map(term => Utils.uuid(term)) } }, - { - employee_number: { - [Op.or]: filter.received_by.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'proof', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.receipt_number) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'payments', - 'receipt_number', - filter.receipt_number, - ), - }; - } - - if (filter.reference_code) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'payments', - 'reference_code', - filter.reference_code, - ), - }; - } - - if (filter.notes) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'payments', - 'notes', - filter.notes, - ), - }; - } - - - - - - - if (filter.paid_atRange) { - const [start, end] = filter.paid_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - paid_at: { - ...where.paid_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - paid_at: { - ...where.paid_at, - [Op.lte]: end, - }, - }; - } - } - - if (filter.amountRange) { - const [start, end] = filter.amountRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - amount: { - ...where.amount, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - amount: { - ...where.amount, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.method) { - where = { - ...where, - method: filter.method, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.payments.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'payments', - 'receipt_number', - query, - ), - ], - }; - } - - const records = await db.payments.findAll({ - attributes: [ 'id', 'receipt_number' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['receipt_number', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.receipt_number, - })); - } - - -}; - diff --git a/backend/src/db/api/payments.ts b/backend/src/db/api/payments.ts new file mode 100644 index 0000000..cbfdc50 --- /dev/null +++ b/backend/src/db/api/payments.ts @@ -0,0 +1,425 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Payments } from '@/db/models/payments'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type PaymentsData = Partial> & { + organization?: string | null; + invoice?: string | null; + received_by?: string | null; + proof?: FileInput | FileInput[] | null; +}; + +interface PaymentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + receipt_number?: string; + reference_code?: string; + notes?: string; + paid_atRange?: Array; + amountRange?: Array; + active?: boolean | string; + method?: string; + invoice?: string; + received_by?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function paymentsTableName(): string { + const name = db.payments.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class PaymentsDBApi { + static async create( + data: PaymentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const payments = await db.payments.create( + { + id: data.id || undefined, + receipt_number: data.receipt_number || null, + paid_at: data.paid_at || null, + amount: data.amount || null, + method: data.method || null, + reference_code: data.reference_code || null, + notes: data.notes || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await payments.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await payments.setInvoice(data.invoice ?? undefined, { transaction }); + await payments.setReceived_by(data.received_by ?? undefined, { + transaction, + }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: paymentsTableName(), + belongsToColumn: 'proof', + belongsToId: payments.id, + }, + data.proof, + options, + ); + + return payments; + } + + static async bulkImport( + data: PaymentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const paymentsData = data.map((item, index) => ({ + id: item.id || undefined, + receipt_number: item.receipt_number || null, + paid_at: item.paid_at || null, + amount: item.amount || null, + method: item.method || null, + reference_code: item.reference_code || null, + notes: item.notes || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const payments = await db.payments.bulkCreate(paymentsData, { transaction }); + + for (let i = 0; i < payments.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: paymentsTableName(), + belongsToColumn: 'proof', + belongsToId: payments[i].id, + }, + data[i].proof, + options, + ); + } + + return payments; + } + + static async update( + id: string, + data: PaymentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const payments = await db.payments.findByPk(id, { transaction }); + + if (!payments) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.receipt_number !== undefined) + updatePayload.receipt_number = data.receipt_number; + if (data.paid_at !== undefined) updatePayload.paid_at = data.paid_at; + if (data.amount !== undefined) updatePayload.amount = data.amount; + if (data.method !== undefined) updatePayload.method = data.method; + if (data.reference_code !== undefined) + updatePayload.reference_code = data.reference_code; + if (data.notes !== undefined) updatePayload.notes = data.notes; + + updatePayload.updatedById = currentUser.id; + + await payments.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await payments.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.invoice !== undefined) { + await payments.setInvoice(data.invoice ?? undefined, { transaction }); + } + if (data.received_by !== undefined) { + await payments.setReceived_by(data.received_by ?? undefined, { + transaction, + }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: paymentsTableName(), + belongsToColumn: 'proof', + belongsToId: payments.id, + }, + data.proof, + options, + ); + + return payments; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.payments, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.payments, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const payments = await db.payments.findOne({ where, transaction }); + + if (!payments) { + return null; + } + + const output: Record = payments.get({ plain: true }); + + const [organization, invoice, received_by, proof] = await Promise.all([ + payments.getOrganization({ transaction }), + payments.getInvoice({ transaction }), + payments.getReceived_by({ transaction }), + payments.getProof({ transaction }), + ]); + output.organization = organization; + output.invoice = invoice; + output.received_by = received_by; + output.proof = proof; + + return output; + } + + static async findAll( + filter: PaymentsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Payments[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.invoices, + as: 'invoice', + where: filter.invoice + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.invoice.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + invoice_number: { + [Op.or]: filter.invoice + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.staff, + as: 'received_by', + where: filter.received_by + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.received_by + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + employee_number: { + [Op.or]: filter.received_by + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'proof' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.receipt_number) { + where = { + ...where, + [Op.and]: Utils.ilike('payments', 'receipt_number', filter.receipt_number), + }; + } + if (filter.reference_code) { + where = { + ...where, + [Op.and]: Utils.ilike('payments', 'reference_code', filter.reference_code), + }; + } + if (filter.notes) { + where = { + ...where, + [Op.and]: Utils.ilike('payments', 'notes', filter.notes), + }; + } + if (filter.paid_atRange) { + const [start, end] = filter.paid_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, paid_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + paid_at: { + ...(typeof where.paid_at === 'object' ? where.paid_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.amountRange) { + const [start, end] = filter.amountRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, amount: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + amount: { + ...(typeof where.amount === 'object' ? where.amount : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.method) { + where = { ...where, method: filter.method }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.payments.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.payments, + 'receipt_number', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default PaymentsDBApi; diff --git a/backend/src/db/api/permissions.js b/backend/src/db/api/permissions.js deleted file mode 100644 index 3c9130c..0000000 --- a/backend/src/db/api/permissions.js +++ /dev/null @@ -1,355 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class PermissionsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const permissions = await db.permissions.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - - - - - - return permissions; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const permissionsData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const permissions = await db.permissions.bulkCreate(permissionsData, { transaction }); - - // For each item created, replace relation files - - - return permissions; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const permissions = await db.permissions.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - updatePayload.updatedById = currentUser.id; - - await permissions.update(updatePayload, {transaction}); - - - - - - - - - - return permissions; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const permissions = await db.permissions.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of permissions) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of permissions) { - await record.destroy({transaction}); - } - }); - - - return permissions; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const permissions = await db.permissions.findByPk(id, options); - - await permissions.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await permissions.destroy({ - transaction - }); - - return permissions; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const permissions = await db.permissions.findOne( - { where }, - { transaction }, - ); - - if (!permissions) { - return permissions; - } - - const output = permissions.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return output; - } - - static async findAll( - filter, - options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'permissions', - 'name', - filter.name, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.permissions.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, ) { - let where = {}; - - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'permissions', - 'name', - query, - ), - ], - }; - } - - const records = await db.permissions.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/permissions.ts b/backend/src/db/api/permissions.ts new file mode 100644 index 0000000..61097a2 --- /dev/null +++ b/backend/src/db/api/permissions.ts @@ -0,0 +1,213 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Permissions } from '@/db/models/permissions'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type PermissionsData = Partial>; + +interface PermissionsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + active?: boolean | string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class PermissionsDBApi { + static async create( + data: PermissionsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + return db.permissions.create( + { + id: data.id || undefined, + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + static async bulkImport( + data: PermissionsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const permissionsData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.permissions.bulkCreate(permissionsData, { transaction }); + } + + static async update( + id: string, + data: PermissionsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const permissions = await db.permissions.findByPk(id, { transaction }); + + if (!permissions) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await permissions.update(updatePayload, { transaction }); + + return permissions; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.permissions, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.permissions, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const permissions = await db.permissions.findOne({ where, transaction }); + + if (!permissions) { + return null; + } + + return permissions.get({ plain: true }); + } + + static async findAll( + filter: PermissionsFilter, + options?: DbApiOptions, + ): Promise<{ rows: Permissions[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('permissions', 'name', filter.name), + }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.permissions.findAndCountAll({ + where, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + ): Promise> { + let where: WhereAttributeHash = {}; + + if (query) { + where = { + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike('permissions', 'name', query), + ], + }; + } + + const records = await db.permissions.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +} + +export default PermissionsDBApi; diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js deleted file mode 100644 index bf4eee2..0000000 --- a/backend/src/db/api/roles.js +++ /dev/null @@ -1,457 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - -const config = require('../../config'); - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class RolesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const roles = await db.roles.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - role_customization: data.role_customization - || - null - , - - globalAccess: data.globalAccess - || - false - - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - - - await roles.setPermissions(data.permissions || [], { - transaction, - }); - - - - - return roles; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const rolesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - role_customization: item.role_customization - || - null - , - - globalAccess: item.globalAccess - || - false - - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const roles = await db.roles.bulkCreate(rolesData, { transaction }); - - // For each item created, replace relation files - - - return roles; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const roles = await db.roles.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.role_customization !== undefined) updatePayload.role_customization = data.role_customization; - - - if (data.globalAccess !== undefined) updatePayload.globalAccess = data.globalAccess; - - - updatePayload.updatedById = currentUser.id; - - await roles.update(updatePayload, {transaction}); - - - - - - - if (data.permissions !== undefined) { - await roles.setPermissions(data.permissions, { transaction }); - } - - - - - return roles; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const roles = await db.roles.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of roles) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of roles) { - await record.destroy({transaction}); - } - }); - - - return roles; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const roles = await db.roles.findByPk(id, options); - - await roles.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await roles.destroy({ - transaction - }); - - return roles; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const roles = await db.roles.findOne( - { where }, - { transaction }, - ); - - if (!roles) { - return roles; - } - - const output = roles.get({plain: true}); - - - output.users_app_role = await roles.getUsers_app_role({ - transaction - }); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.permissions = await roles.getPermissions({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - - { - model: db.permissions, - as: 'permissions', - required: false, - }, - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'roles', - 'name', - filter.name, - ), - }; - } - - if (filter.role_customization) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'roles', - 'role_customization', - filter.role_customization, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.globalAccess) { - where = { - ...where, - globalAccess: filter.globalAccess, - }; - } - - - - - if (filter.permissions) { - const searchTerms = filter.permissions.split('|'); - - include = [ - { - model: db.permissions, - as: 'permissions_filter', - required: searchTerms.length > 0, - where: searchTerms.length > 0 ? { - [Op.or]: [ - { id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` })) - } - } - ] - } : undefined - }, - ...include, - ] - } - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - if (!globalAccess) { - where = { name: { [Op.ne]: config.roles.super_admin } }; - } - - - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.roles.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess,) { - let where = {}; - - if (!globalAccess) { - where = { name: { [Op.ne]: config.roles.super_admin } }; - } - - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'roles', - 'name', - query, - ), - ], - }; - } - - const records = await db.roles.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/roles.ts b/backend/src/db/api/roles.ts new file mode 100644 index 0000000..41897d5 --- /dev/null +++ b/backend/src/db/api/roles.ts @@ -0,0 +1,280 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import config from '@/shared/config'; +import type { Roles } from '@/db/models/roles'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type RolesData = Partial> & { + permissions?: string[]; +}; + +interface RolesFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + active?: boolean | string; + globalAccess?: boolean | string; + permissions?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class RolesDBApi { + static async create( + data: RolesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const roles = await db.roles.create( + { + id: data.id || undefined, + name: data.name || null, + globalAccess: data.globalAccess || false, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await roles.setPermissions(data.permissions || [], { transaction }); + + return roles; + } + + static async bulkImport( + data: RolesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const rolesData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + globalAccess: item.globalAccess || false, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.roles.bulkCreate(rolesData, { transaction }); + } + + static async update( + id: string, + data: RolesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const roles = await db.roles.findByPk(id, { transaction }); + + if (!roles) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.globalAccess !== undefined) + updatePayload.globalAccess = data.globalAccess; + + updatePayload.updatedById = currentUser.id; + + await roles.update(updatePayload, { transaction }); + + if (data.permissions !== undefined) { + await roles.setPermissions(data.permissions, { transaction }); + } + + return roles; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.roles, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.roles, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const roles = await db.roles.findOne({ where, transaction }); + + if (!roles) { + return null; + } + + const output: Record = roles.get({ plain: true }); + + const [users_app_role, permissions] = await Promise.all([ + roles.getUsers_app_role({ transaction }), + roles.getPermissions({ transaction }), + ]); + output.users_app_role = users_app_role; + output.permissions = permissions; + + return output; + } + + static async findAll( + filter: RolesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Roles[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + let include: Includeable[] = [ + { model: db.permissions, as: 'permissions', required: false }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { ...where, [Op.and]: Utils.ilike('roles', 'name', filter.name) }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.globalAccess) { + where = { ...where, globalAccess: filter.globalAccess }; + } + if (filter.permissions) { + const searchTerms = filter.permissions.split('|'); + include = [ + { + model: db.permissions, + as: 'permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { id: { [Op.in]: searchTerms.map((term) => Utils.uuid(term)) } }, + { + name: { + [Op.or]: searchTerms.map((term) => ({ + [Op.iLike]: `%${term}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (!globalAccess) { + where = { name: { [Op.ne]: config.roles.super_admin } }; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.roles.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + ): Promise> { + let where: WhereAttributeHash = {}; + + if (!globalAccess) { + where = { name: { [Op.ne]: config.roles.super_admin } }; + } + + if (query) { + where = { + [Op.or]: [ + { id: Utils.uuid(query) }, + Utils.ilike('roles', 'name', query), + ], + }; + } + + const records = await db.roles.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +} + +export default RolesDBApi; diff --git a/backend/src/db/api/shared/repository.ts b/backend/src/db/api/shared/repository.ts new file mode 100644 index 0000000..670ab0a --- /dev/null +++ b/backend/src/db/api/shared/repository.ts @@ -0,0 +1,94 @@ +import { + Op, + type Model, + type ModelStatic, + type WhereOptions, +} from 'sequelize'; +import Utils from '@/db/utils'; +import type { DbApiOptions } from '@/db/api/types'; + +/** + * Shared generic-repository helpers for the `db/api` layer. They cover the + * methods that are byte-identical across every entity (`remove`, `deleteByIds`) + * and the single-field autocomplete, parameterized by the model. Entity-specific + * methods (`create`/`update`/`bulkImport`/`findBy`/`findAll`) stay in each + * repository. + */ + +/** Finds a record by id and soft-deletes it (returns null when absent). */ +export async function removeRecord( + model: ModelStatic, + id: string, + options?: DbApiOptions, +): Promise { + const transaction = options?.transaction; + const record = await model.findByPk(id, { transaction }); + + if (!record) { + return null; + } + + await record.destroy({ transaction }); + return record; +} + +/** Deletes every record whose id is in `ids` (within the caller's transaction). */ +export async function deleteRecordsByIds( + model: ModelStatic, + ids: string[], + options?: DbApiOptions, +): Promise { + const transaction = options?.transaction; + const where: WhereOptions = { id: { [Op.in]: ids } }; + + const records = await model.findAll({ where, transaction }); + + for (const record of records) { + await record.destroy({ transaction }); + } + + return records; +} + +/** + * Autocomplete over a single label column: `{ id, label }` rows matching `query` + * (by id or case-insensitive substring), scoped to the tenant unless global. + */ +export async function autocompleteByField( + model: ModelStatic, + field: string, + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, +): Promise> { + let where: WhereOptions = {}; + + if (!globalAccess && organizationId) { + where = { organizationId }; + } + + if (query) { + where = { + [Op.or]: [{ id: Utils.uuid(query) }, Utils.ilike(model.name, field, query)], + }; + } + + const records = await model.findAll({ + attributes: ['id', field], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [[field, 'ASC']], + }); + + return records.map((record) => { + const id = record.get('id'); + const label = record.get(field); + return { + id: typeof id === 'string' ? id : String(id), + label: typeof label === 'string' ? label : null, + }; + }); +} diff --git a/backend/src/db/api/staff.js b/backend/src/db/api/staff.js deleted file mode 100644 index 77b2220..0000000 --- a/backend/src/db/api/staff.js +++ /dev/null @@ -1,639 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class StaffDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const staff = await db.staff.create( - { - id: data.id || undefined, - - employee_number: data.employee_number - || - null - , - - job_title: data.job_title - || - null - , - - staff_type: data.staff_type - || - null - , - - hire_date: data.hire_date - || - null - , - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await staff.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await staff.setCampus( data.campus || null, { - transaction, - }); - - await staff.setUser( data.user || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.staff.getTableName(), - belongsToColumn: 'photo', - belongsToId: staff.id, - }, - data.photo, - options, - ); - - - return staff; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const staffData = data.map((item, index) => ({ - id: item.id || undefined, - - employee_number: item.employee_number - || - null - , - - job_title: item.job_title - || - null - , - - staff_type: item.staff_type - || - null - , - - hire_date: item.hire_date - || - null - , - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const staff = await db.staff.bulkCreate(staffData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < staff.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.staff.getTableName(), - belongsToColumn: 'photo', - belongsToId: staff[i].id, - }, - data[i].photo, - options, - ); - } - - - return staff; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const staff = await db.staff.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.employee_number !== undefined) updatePayload.employee_number = data.employee_number; - - - if (data.job_title !== undefined) updatePayload.job_title = data.job_title; - - - if (data.staff_type !== undefined) updatePayload.staff_type = data.staff_type; - - - if (data.hire_date !== undefined) updatePayload.hire_date = data.hire_date; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await staff.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await staff.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await staff.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.user !== undefined) { - await staff.setUser( - - data.user, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.staff.getTableName(), - belongsToColumn: 'photo', - belongsToId: staff.id, - }, - data.photo, - options, - ); - - - return staff; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const staff = await db.staff.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of staff) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of staff) { - await record.destroy({transaction}); - } - }); - - - return staff; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const staff = await db.staff.findByPk(id, options); - - await staff.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await staff.destroy({ - transaction - }); - - return staff; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const staff = await db.staff.findOne( - { where }, - { transaction }, - ); - - if (!staff) { - return staff; - } - - const output = staff.get({plain: true}); - - - - - - - - - - - - - - output.classes_homeroom_teacher = await staff.getClasses_homeroom_teacher({ - transaction - }); - - - - output.class_subjects_teacher = await staff.getClass_subjects_teacher({ - transaction - }); - - - - - output.attendance_sessions_taken_by = await staff.getAttendance_sessions_taken_by({ - transaction - }); - - - - - - output.payments_received_by = await staff.getPayments_received_by({ - transaction - }); - - - - - - - - - output.organization = await staff.getOrganization({ - transaction - }); - - - output.campus = await staff.getCampus({ - transaction - }); - - - output.user = await staff.getUser({ - transaction - }); - - - output.photo = await staff.getPhoto({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.users, - as: 'user', - - where: filter.user ? { - [Op.or]: [ - { id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } }, - { - firstName: { - [Op.or]: filter.user.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'photo', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.employee_number) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'staff', - 'employee_number', - filter.employee_number, - ), - }; - } - - if (filter.job_title) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'staff', - 'job_title', - filter.job_title, - ), - }; - } - - - - - - - if (filter.hire_dateRange) { - const [start, end] = filter.hire_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - hire_date: { - ...where.hire_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - hire_date: { - ...where.hire_date, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.staff_type) { - where = { - ...where, - staff_type: filter.staff_type, - }; - } - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.staff.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'staff', - 'employee_number', - query, - ), - ], - }; - } - - const records = await db.staff.findAll({ - attributes: [ 'id', 'employee_number' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['employee_number', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.employee_number, - })); - } - - -}; - diff --git a/backend/src/db/api/staff.ts b/backend/src/db/api/staff.ts new file mode 100644 index 0000000..e178acf --- /dev/null +++ b/backend/src/db/api/staff.ts @@ -0,0 +1,414 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Staff } from '@/db/models/staff'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type StaffData = Partial> & { + organization?: string | null; + campus?: string | null; + user?: string | null; + photo?: FileInput | FileInput[] | null; +}; + +interface StaffFilter { + limit?: number | string; + page?: number | string; + id?: string; + employee_number?: string; + job_title?: string; + hire_dateRange?: Array; + active?: boolean | string; + staff_type?: string; + status?: string; + campus?: string; + user?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function staffTableName(): string { + const name = db.staff.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class StaffDBApi { + static async create( + data: StaffData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const staff = await db.staff.create( + { + id: data.id || undefined, + employee_number: data.employee_number || null, + job_title: data.job_title || null, + staff_type: data.staff_type || null, + hire_date: data.hire_date || null, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await staff.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await staff.setCampus(data.campus ?? undefined, { transaction }); + await staff.setUser(data.user ?? undefined, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: staffTableName(), + belongsToColumn: 'photo', + belongsToId: staff.id, + }, + data.photo, + options, + ); + + return staff; + } + + static async bulkImport( + data: StaffData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const staffData = data.map((item, index) => ({ + id: item.id || undefined, + employee_number: item.employee_number || null, + job_title: item.job_title || null, + staff_type: item.staff_type || null, + hire_date: item.hire_date || null, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const staff = await db.staff.bulkCreate(staffData, { transaction }); + + for (let i = 0; i < staff.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: staffTableName(), + belongsToColumn: 'photo', + belongsToId: staff[i].id, + }, + data[i].photo, + options, + ); + } + + return staff; + } + + static async update( + id: string, + data: StaffData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const staff = await db.staff.findByPk(id, { transaction }); + + if (!staff) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.employee_number !== undefined) + updatePayload.employee_number = data.employee_number; + if (data.job_title !== undefined) updatePayload.job_title = data.job_title; + if (data.staff_type !== undefined) + updatePayload.staff_type = data.staff_type; + if (data.hire_date !== undefined) updatePayload.hire_date = data.hire_date; + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await staff.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await staff.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await staff.setCampus(data.campus ?? undefined, { transaction }); + } + if (data.user !== undefined) { + await staff.setUser(data.user ?? undefined, { transaction }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: staffTableName(), + belongsToColumn: 'photo', + belongsToId: staff.id, + }, + data.photo, + options, + ); + + return staff; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.staff, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.staff, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const staff = await db.staff.findOne({ where, transaction }); + + if (!staff) { + return null; + } + + const output: Record = staff.get({ plain: true }); + + const [ + classes_homeroom_teacher, + class_subjects_teacher, + attendance_sessions_taken_by, + payments_received_by, + organization, + campus, + user, + photo, + ] = await Promise.all([ + staff.getClasses_homeroom_teacher({ transaction }), + staff.getClass_subjects_teacher({ transaction }), + staff.getAttendance_sessions_taken_by({ transaction }), + staff.getPayments_received_by({ transaction }), + staff.getOrganization({ transaction }), + staff.getCampus({ transaction }), + staff.getUser({ transaction }), + staff.getPhoto({ transaction }), + ]); + output.classes_homeroom_teacher = classes_homeroom_teacher; + output.class_subjects_teacher = class_subjects_teacher; + output.attendance_sessions_taken_by = attendance_sessions_taken_by; + output.payments_received_by = payments_received_by; + output.organization = organization; + output.campus = campus; + output.user = user; + output.photo = photo; + + return output; + } + + static async findAll( + filter: StaffFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Staff[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.users, + as: 'user', + where: filter.user + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.user.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + firstName: { + [Op.or]: filter.user + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'photo' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.employee_number) { + where = { + ...where, + [Op.and]: Utils.ilike('staff', 'employee_number', filter.employee_number), + }; + } + if (filter.job_title) { + where = { + ...where, + [Op.and]: Utils.ilike('staff', 'job_title', filter.job_title), + }; + } + if (filter.hire_dateRange) { + const [start, end] = filter.hire_dateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, hire_date: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + hire_date: { + ...(typeof where.hire_date === 'object' ? where.hire_date : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.staff_type) { + where = { ...where, staff_type: filter.staff_type }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.staff.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.staff, + 'employee_number', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default StaffDBApi; diff --git a/backend/src/db/api/students.js b/backend/src/db/api/students.js deleted file mode 100644 index f100de6..0000000 --- a/backend/src/db/api/students.js +++ /dev/null @@ -1,739 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class StudentsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const students = await db.students.create( - { - id: data.id || undefined, - - student_number: data.student_number - || - null - , - - first_name: data.first_name - || - null - , - - last_name: data.last_name - || - null - , - - gender: data.gender - || - null - , - - date_of_birth: data.date_of_birth - || - null - , - - enrollment_date: data.enrollment_date - || - null - , - - status: data.status - || - null - , - - email: data.email - || - null - , - - phone: data.phone - || - null - , - - address: data.address - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await students.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await students.setCampus( data.campus || null, { - transaction, - }); - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.students.getTableName(), - belongsToColumn: 'photo', - belongsToId: students.id, - }, - data.photo, - options, - ); - - - return students; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const studentsData = data.map((item, index) => ({ - id: item.id || undefined, - - student_number: item.student_number - || - null - , - - first_name: item.first_name - || - null - , - - last_name: item.last_name - || - null - , - - gender: item.gender - || - null - , - - date_of_birth: item.date_of_birth - || - null - , - - enrollment_date: item.enrollment_date - || - null - , - - status: item.status - || - null - , - - email: item.email - || - null - , - - phone: item.phone - || - null - , - - address: item.address - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const students = await db.students.bulkCreate(studentsData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < students.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.students.getTableName(), - belongsToColumn: 'photo', - belongsToId: students[i].id, - }, - data[i].photo, - options, - ); - } - - - return students; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const students = await db.students.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.student_number !== undefined) updatePayload.student_number = data.student_number; - - - if (data.first_name !== undefined) updatePayload.first_name = data.first_name; - - - if (data.last_name !== undefined) updatePayload.last_name = data.last_name; - - - if (data.gender !== undefined) updatePayload.gender = data.gender; - - - if (data.date_of_birth !== undefined) updatePayload.date_of_birth = data.date_of_birth; - - - if (data.enrollment_date !== undefined) updatePayload.enrollment_date = data.enrollment_date; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - if (data.email !== undefined) updatePayload.email = data.email; - - - if (data.phone !== undefined) updatePayload.phone = data.phone; - - - if (data.address !== undefined) updatePayload.address = data.address; - - - updatePayload.updatedById = currentUser.id; - - await students.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await students.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await students.setCampus( - - data.campus, - - { transaction } - ); - } - - - - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.students.getTableName(), - belongsToColumn: 'photo', - belongsToId: students.id, - }, - data.photo, - options, - ); - - - return students; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const students = await db.students.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of students) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of students) { - await record.destroy({transaction}); - } - }); - - - return students; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const students = await db.students.findByPk(id, options); - - await students.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await students.destroy({ - transaction - }); - - return students; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const students = await db.students.findOne( - { where }, - { transaction }, - ); - - if (!students) { - return students; - } - - const output = students.get({plain: true}); - - - - - - - - - - - - output.guardians_student = await students.getGuardians_student({ - transaction - }); - - - - - output.class_enrollments_student = await students.getClass_enrollments_student({ - transaction - }); - - - - - - - output.attendance_records_student = await students.getAttendance_records_student({ - transaction - }); - - - - output.invoices_student = await students.getInvoices_student({ - transaction - }); - - - - - output.assessment_results_student = await students.getAssessment_results_student({ - transaction - }); - - - - - - - output.organization = await students.getOrganization({ - transaction - }); - - - output.campus = await students.getCampus({ - transaction - }); - - - output.photo = await students.getPhoto({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - { - model: db.file, - as: 'photo', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.student_number) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'student_number', - filter.student_number, - ), - }; - } - - if (filter.first_name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'first_name', - filter.first_name, - ), - }; - } - - if (filter.last_name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'last_name', - filter.last_name, - ), - }; - } - - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'email', - filter.email, - ), - }; - } - - if (filter.phone) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'phone', - filter.phone, - ), - }; - } - - if (filter.address) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'students', - 'address', - filter.address, - ), - }; - } - - - - - - - if (filter.date_of_birthRange) { - const [start, end] = filter.date_of_birthRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - date_of_birth: { - ...where.date_of_birth, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - date_of_birth: { - ...where.date_of_birth, - [Op.lte]: end, - }, - }; - } - } - - if (filter.enrollment_dateRange) { - const [start, end] = filter.enrollment_dateRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - enrollment_date: { - ...where.enrollment_date, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - enrollment_date: { - ...where.enrollment_date, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.gender) { - where = { - ...where, - gender: filter.gender, - }; - } - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.students.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'students', - 'student_number', - query, - ), - ], - }; - } - - const records = await db.students.findAll({ - attributes: [ 'id', 'student_number' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['student_number', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.student_number, - })); - } - - -}; - diff --git a/backend/src/db/api/students.ts b/backend/src/db/api/students.ts new file mode 100644 index 0000000..cdfce1d --- /dev/null +++ b/backend/src/db/api/students.ts @@ -0,0 +1,451 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import FileDBApi from '@/db/api/file'; +import type { Students } from '@/db/models/students'; +import type { CurrentUser, DbApiOptions, FileInput } from '@/db/api/types'; + +type StudentsData = Partial> & { + organization?: string | null; + campus?: string | null; + photo?: FileInput | FileInput[] | null; +}; + +interface StudentsFilter { + limit?: number | string; + page?: number | string; + id?: string; + student_number?: string; + first_name?: string; + last_name?: string; + email?: string; + phone?: string; + address?: string; + date_of_birthRange?: Array; + enrollment_dateRange?: Array; + active?: boolean | string; + gender?: string; + status?: string; + campus?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function studentsTableName(): string { + const name = db.students.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +class StudentsDBApi { + static async create( + data: StudentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const students = await db.students.create( + { + id: data.id || undefined, + student_number: data.student_number || null, + first_name: data.first_name || null, + last_name: data.last_name || null, + gender: data.gender || null, + date_of_birth: data.date_of_birth || null, + enrollment_date: data.enrollment_date || null, + status: data.status || null, + email: data.email || null, + phone: data.phone || null, + address: data.address || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await students.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await students.setCampus(data.campus ?? undefined, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: studentsTableName(), + belongsToColumn: 'photo', + belongsToId: students.id, + }, + data.photo, + options, + ); + + return students; + } + + static async bulkImport( + data: StudentsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const studentsData = data.map((item, index) => ({ + id: item.id || undefined, + student_number: item.student_number || null, + first_name: item.first_name || null, + last_name: item.last_name || null, + gender: item.gender || null, + date_of_birth: item.date_of_birth || null, + enrollment_date: item.enrollment_date || null, + status: item.status || null, + email: item.email || null, + phone: item.phone || null, + address: item.address || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const students = await db.students.bulkCreate(studentsData, { transaction }); + + for (let i = 0; i < students.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: studentsTableName(), + belongsToColumn: 'photo', + belongsToId: students[i].id, + }, + data[i].photo, + options, + ); + } + + return students; + } + + static async update( + id: string, + data: StudentsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const students = await db.students.findByPk(id, { transaction }); + + if (!students) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.student_number !== undefined) + updatePayload.student_number = data.student_number; + if (data.first_name !== undefined) + updatePayload.first_name = data.first_name; + if (data.last_name !== undefined) updatePayload.last_name = data.last_name; + if (data.gender !== undefined) updatePayload.gender = data.gender; + if (data.date_of_birth !== undefined) + updatePayload.date_of_birth = data.date_of_birth; + if (data.enrollment_date !== undefined) + updatePayload.enrollment_date = data.enrollment_date; + if (data.status !== undefined) updatePayload.status = data.status; + if (data.email !== undefined) updatePayload.email = data.email; + if (data.phone !== undefined) updatePayload.phone = data.phone; + if (data.address !== undefined) updatePayload.address = data.address; + + updatePayload.updatedById = currentUser.id; + + await students.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await students.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await students.setCampus(data.campus ?? undefined, { transaction }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: studentsTableName(), + belongsToColumn: 'photo', + belongsToId: students.id, + }, + data.photo, + options, + ); + + return students; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.students, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.students, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const students = await db.students.findOne({ where, transaction }); + + if (!students) { + return null; + } + + const output: Record = students.get({ plain: true }); + + const [ + guardians_student, + class_enrollments_student, + attendance_records_student, + invoices_student, + assessment_results_student, + organization, + campus, + photo, + ] = await Promise.all([ + students.getGuardians_student({ transaction }), + students.getClass_enrollments_student({ transaction }), + students.getAttendance_records_student({ transaction }), + students.getInvoices_student({ transaction }), + students.getAssessment_results_student({ transaction }), + students.getOrganization({ transaction }), + students.getCampus({ transaction }), + students.getPhoto({ transaction }), + ]); + output.guardians_student = guardians_student; + output.class_enrollments_student = class_enrollments_student; + output.attendance_records_student = attendance_records_student; + output.invoices_student = invoices_student; + output.assessment_results_student = assessment_results_student; + output.organization = organization; + output.campus = campus; + output.photo = photo; + + return output; + } + + static async findAll( + filter: StudentsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Students[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.file, as: 'photo' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.student_number) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'student_number', filter.student_number), + }; + } + if (filter.first_name) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'first_name', filter.first_name), + }; + } + if (filter.last_name) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'last_name', filter.last_name), + }; + } + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'email', filter.email), + }; + } + if (filter.phone) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'phone', filter.phone), + }; + } + if (filter.address) { + where = { + ...where, + [Op.and]: Utils.ilike('students', 'address', filter.address), + }; + } + if (filter.date_of_birthRange) { + const [start, end] = filter.date_of_birthRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, date_of_birth: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + date_of_birth: { + ...(typeof where.date_of_birth === 'object' + ? where.date_of_birth + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.enrollment_dateRange) { + const [start, end] = filter.enrollment_dateRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, enrollment_date: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + enrollment_date: { + ...(typeof where.enrollment_date === 'object' + ? where.enrollment_date + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.gender) { + where = { ...where, gender: filter.gender }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.students.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.students, + 'student_number', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default StudentsDBApi; diff --git a/backend/src/db/api/subjects.js b/backend/src/db/api/subjects.js deleted file mode 100644 index 30d075b..0000000 --- a/backend/src/db/api/subjects.js +++ /dev/null @@ -1,458 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class SubjectsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const subjects = await db.subjects.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - code: data.code - || - null - , - - description: data.description - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await subjects.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - - - - - - return subjects; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const subjectsData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - code: item.code - || - null - , - - description: item.description - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const subjects = await db.subjects.bulkCreate(subjectsData, { transaction }); - - // For each item created, replace relation files - - - return subjects; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const subjects = await db.subjects.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.code !== undefined) updatePayload.code = data.code; - - - if (data.description !== undefined) updatePayload.description = data.description; - - - updatePayload.updatedById = currentUser.id; - - await subjects.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await subjects.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - - - - - - - return subjects; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const subjects = await db.subjects.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of subjects) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of subjects) { - await record.destroy({transaction}); - } - }); - - - return subjects; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const subjects = await db.subjects.findByPk(id, options); - - await subjects.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await subjects.destroy({ - transaction - }); - - return subjects; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const subjects = await db.subjects.findOne( - { where }, - { transaction }, - ); - - if (!subjects) { - return subjects; - } - - const output = subjects.get({plain: true}); - - - - - - - - - - - - - - - - output.class_subjects_subject = await subjects.getClass_subjects_subject({ - transaction - }); - - - - - - - - - - - - - - - - output.organization = await subjects.getOrganization({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'subjects', - 'name', - filter.name, - ), - }; - } - - if (filter.code) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'subjects', - 'code', - filter.code, - ), - }; - } - - if (filter.description) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'subjects', - 'description', - filter.description, - ), - }; - } - - - - - - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.subjects.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'subjects', - 'name', - query, - ), - ], - }; - } - - const records = await db.subjects.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/subjects.ts b/backend/src/db/api/subjects.ts new file mode 100644 index 0000000..0900d90 --- /dev/null +++ b/backend/src/db/api/subjects.ts @@ -0,0 +1,266 @@ +import { + Op, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Subjects } from '@/db/models/subjects'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type SubjectsData = Partial> & { + organization?: string | null; +}; + +interface SubjectsFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + code?: string; + description?: string; + active?: boolean | string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class SubjectsDBApi { + static async create( + data: SubjectsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const subjects = await db.subjects.create( + { + id: data.id || undefined, + name: data.name || null, + code: data.code || null, + description: data.description || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await subjects.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + + return subjects; + } + + static async bulkImport( + data: SubjectsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const subjectsData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + code: item.code || null, + description: item.description || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.subjects.bulkCreate(subjectsData, { transaction }); + } + + static async update( + id: string, + data: SubjectsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const subjects = await db.subjects.findByPk(id, { transaction }); + + if (!subjects) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.code !== undefined) updatePayload.code = data.code; + if (data.description !== undefined) + updatePayload.description = data.description; + + updatePayload.updatedById = currentUser.id; + + await subjects.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await subjects.setOrganization(orgId ?? undefined, { transaction }); + } + + return subjects; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.subjects, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.subjects, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const subjects = await db.subjects.findOne({ where, transaction }); + + if (!subjects) { + return null; + } + + const output: Record = subjects.get({ plain: true }); + + const [class_subjects_subject, organization] = await Promise.all([ + subjects.getClass_subjects_subject({ transaction }), + subjects.getOrganization({ transaction }), + ]); + output.class_subjects_subject = class_subjects_subject; + output.organization = organization; + + return output; + } + + static async findAll( + filter: SubjectsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Subjects[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('subjects', 'name', filter.name), + }; + } + if (filter.code) { + where = { + ...where, + [Op.and]: Utils.ilike('subjects', 'code', filter.code), + }; + } + if (filter.description) { + where = { + ...where, + [Op.and]: Utils.ilike('subjects', 'description', filter.description), + }; + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.subjects.findAndCountAll({ + where, + include: [{ model: db.organizations, as: 'organization' }], + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.subjects, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default SubjectsDBApi; diff --git a/backend/src/db/api/timetable_periods.js b/backend/src/db/api/timetable_periods.js deleted file mode 100644 index e623993..0000000 --- a/backend/src/db/api/timetable_periods.js +++ /dev/null @@ -1,574 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class Timetable_periodsDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const timetable_periods = await db.timetable_periods.create( - { - id: data.id || undefined, - - day_of_week: data.day_of_week - || - null - , - - starts_at: data.starts_at - || - null - , - - ends_at: data.ends_at - || - null - , - - room: data.room - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await timetable_periods.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await timetable_periods.setTimetable( data.timetable || null, { - transaction, - }); - - await timetable_periods.setClass_subject( data.class_subject || null, { - transaction, - }); - - - - - - - return timetable_periods; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const timetable_periodsData = data.map((item, index) => ({ - id: item.id || undefined, - - day_of_week: item.day_of_week - || - null - , - - starts_at: item.starts_at - || - null - , - - ends_at: item.ends_at - || - null - , - - room: item.room - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const timetable_periods = await db.timetable_periods.bulkCreate(timetable_periodsData, { transaction }); - - // For each item created, replace relation files - - - return timetable_periods; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const timetable_periods = await db.timetable_periods.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.day_of_week !== undefined) updatePayload.day_of_week = data.day_of_week; - - - if (data.starts_at !== undefined) updatePayload.starts_at = data.starts_at; - - - if (data.ends_at !== undefined) updatePayload.ends_at = data.ends_at; - - - if (data.room !== undefined) updatePayload.room = data.room; - - - updatePayload.updatedById = currentUser.id; - - await timetable_periods.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await timetable_periods.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.timetable !== undefined) { - await timetable_periods.setTimetable( - - data.timetable, - - { transaction } - ); - } - - if (data.class_subject !== undefined) { - await timetable_periods.setClass_subject( - - data.class_subject, - - { transaction } - ); - } - - - - - - - - return timetable_periods; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const timetable_periods = await db.timetable_periods.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of timetable_periods) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of timetable_periods) { - await record.destroy({transaction}); - } - }); - - - return timetable_periods; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const timetable_periods = await db.timetable_periods.findByPk(id, options); - - await timetable_periods.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await timetable_periods.destroy({ - transaction - }); - - return timetable_periods; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const timetable_periods = await db.timetable_periods.findOne( - { where }, - { transaction }, - ); - - if (!timetable_periods) { - return timetable_periods; - } - - const output = timetable_periods.get({plain: true}); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - output.organization = await timetable_periods.getOrganization({ - transaction - }); - - - output.timetable = await timetable_periods.getTimetable({ - transaction - }); - - - output.class_subject = await timetable_periods.getClass_subject({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.timetables, - as: 'timetable', - - where: filter.timetable ? { - [Op.or]: [ - { id: { [Op.in]: filter.timetable.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.timetable.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.class_subjects, - as: 'class_subject', - - where: filter.class_subject ? { - [Op.or]: [ - { id: { [Op.in]: filter.class_subject.split('|').map(term => Utils.uuid(term)) } }, - { - status: { - [Op.or]: filter.class_subject.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.room) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'timetable_periods', - 'room', - filter.room, - ), - }; - } - - - - - - - if (filter.starts_atRange) { - const [start, end] = filter.starts_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - starts_at: { - ...where.starts_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - starts_at: { - ...where.starts_at, - [Op.lte]: end, - }, - }; - } - } - - if (filter.ends_atRange) { - const [start, end] = filter.ends_atRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ends_at: { - ...where.ends_at, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ends_at: { - ...where.ends_at, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.day_of_week) { - where = { - ...where, - day_of_week: filter.day_of_week, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.timetable_periods.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'timetable_periods', - 'room', - query, - ), - ], - }; - } - - const records = await db.timetable_periods.findAll({ - attributes: [ 'id', 'room' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['room', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.room, - })); - } - - -}; - diff --git a/backend/src/db/api/timetable_periods.ts b/backend/src/db/api/timetable_periods.ts new file mode 100644 index 0000000..29009ec --- /dev/null +++ b/backend/src/db/api/timetable_periods.ts @@ -0,0 +1,380 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { TimetablePeriods } from '@/db/models/timetable_periods'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type TimetablePeriodsData = Partial< + InferCreationAttributes +> & { + organization?: string | null; + timetable?: string | null; + class_subject?: string | null; +}; + +interface TimetablePeriodsFilter { + limit?: number | string; + page?: number | string; + id?: string; + room?: string; + starts_atRange?: Array; + ends_atRange?: Array; + active?: boolean | string; + day_of_week?: string; + timetable?: string; + class_subject?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class Timetable_periodsDBApi { + static async create( + data: TimetablePeriodsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const timetable_periods = await db.timetable_periods.create( + { + id: data.id || undefined, + day_of_week: data.day_of_week || null, + starts_at: data.starts_at || null, + ends_at: data.ends_at || null, + room: data.room || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await timetable_periods.setOrganization( + currentUser.organizationId ?? undefined, + { transaction }, + ); + await timetable_periods.setTimetable(data.timetable ?? undefined, { + transaction, + }); + await timetable_periods.setClass_subject(data.class_subject ?? undefined, { + transaction, + }); + + return timetable_periods; + } + + static async bulkImport( + data: TimetablePeriodsData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const timetable_periodsData = data.map((item, index) => ({ + id: item.id || undefined, + day_of_week: item.day_of_week || null, + starts_at: item.starts_at || null, + ends_at: item.ends_at || null, + room: item.room || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.timetable_periods.bulkCreate(timetable_periodsData, { + transaction, + }); + } + + static async update( + id: string, + data: TimetablePeriodsData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const timetable_periods = await db.timetable_periods.findByPk(id, { + transaction, + }); + + if (!timetable_periods) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.day_of_week !== undefined) + updatePayload.day_of_week = data.day_of_week; + if (data.starts_at !== undefined) updatePayload.starts_at = data.starts_at; + if (data.ends_at !== undefined) updatePayload.ends_at = data.ends_at; + if (data.room !== undefined) updatePayload.room = data.room; + + updatePayload.updatedById = currentUser.id; + + await timetable_periods.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await timetable_periods.setOrganization(orgId ?? undefined, { + transaction, + }); + } + if (data.timetable !== undefined) { + await timetable_periods.setTimetable(data.timetable ?? undefined, { + transaction, + }); + } + if (data.class_subject !== undefined) { + await timetable_periods.setClass_subject(data.class_subject ?? undefined, { + transaction, + }); + } + + return timetable_periods; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.timetable_periods, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.timetable_periods, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const timetable_periods = await db.timetable_periods.findOne({ + where, + transaction, + }); + + if (!timetable_periods) { + return null; + } + + const output: Record = timetable_periods.get({ + plain: true, + }); + + const [organization, timetable, class_subject] = await Promise.all([ + timetable_periods.getOrganization({ transaction }), + timetable_periods.getTimetable({ transaction }), + timetable_periods.getClass_subject({ transaction }), + ]); + output.organization = organization; + output.timetable = timetable; + output.class_subject = class_subject; + + return output; + } + + static async findAll( + filter: TimetablePeriodsFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: TimetablePeriods[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.timetables, + as: 'timetable', + where: filter.timetable + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.timetable + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.timetable + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.class_subjects, + as: 'class_subject', + where: filter.class_subject + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.class_subject + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + status: { + [Op.or]: filter.class_subject + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.room) { + where = { + ...where, + [Op.and]: Utils.ilike('timetable_periods', 'room', filter.room), + }; + } + if (filter.starts_atRange) { + const [start, end] = filter.starts_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, starts_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + starts_at: { + ...(typeof where.starts_at === 'object' ? where.starts_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.ends_atRange) { + const [start, end] = filter.ends_atRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, ends_at: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ends_at: { + ...(typeof where.ends_at === 'object' ? where.ends_at : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.day_of_week) { + where = { ...where, day_of_week: filter.day_of_week }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.timetable_periods.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.timetable_periods, + 'room', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default Timetable_periodsDBApi; diff --git a/backend/src/db/api/timetables.js b/backend/src/db/api/timetables.js deleted file mode 100644 index 7eaf72c..0000000 --- a/backend/src/db/api/timetables.js +++ /dev/null @@ -1,596 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class TimetablesDBApi { - - - - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const timetables = await db.timetables.create( - { - id: data.id || undefined, - - name: data.name - || - null - , - - effective_from: data.effective_from - || - null - , - - effective_to: data.effective_to - || - null - , - - status: data.status - || - null - , - - importHash: data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - await timetables.setOrganization(currentUser.organization.id || null, { - transaction, - }); - - await timetables.setCampus( data.campus || null, { - transaction, - }); - - await timetables.setAcademic_year( data.academic_year || null, { - transaction, - }); - - - - - - - return timetables; - } - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const timetablesData = data.map((item, index) => ({ - id: item.id || undefined, - - name: item.name - || - null - , - - effective_from: item.effective_from - || - null - , - - effective_to: item.effective_to - || - null - , - - status: item.status - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const timetables = await db.timetables.bulkCreate(timetablesData, { transaction }); - - // For each item created, replace relation files - - - return timetables; - } - - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - - const timetables = await db.timetables.findByPk(id, {}, {transaction}); - - - - - const updatePayload = {}; - - if (data.name !== undefined) updatePayload.name = data.name; - - - if (data.effective_from !== undefined) updatePayload.effective_from = data.effective_from; - - - if (data.effective_to !== undefined) updatePayload.effective_to = data.effective_to; - - - if (data.status !== undefined) updatePayload.status = data.status; - - - updatePayload.updatedById = currentUser.id; - - await timetables.update(updatePayload, {transaction}); - - - - if (data.organization !== undefined) { - await timetables.setOrganization( - - (globalAccess ? data.organization : currentUser.organization.id), - - { transaction } - ); - } - - if (data.campus !== undefined) { - await timetables.setCampus( - - data.campus, - - { transaction } - ); - } - - if (data.academic_year !== undefined) { - await timetables.setAcademic_year( - - data.academic_year, - - { transaction } - ); - } - - - - - - - - return timetables; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const timetables = await db.timetables.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of timetables) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of timetables) { - await record.destroy({transaction}); - } - }); - - - return timetables; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const timetables = await db.timetables.findByPk(id, options); - - await timetables.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await timetables.destroy({ - transaction - }); - - return timetables; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const timetables = await db.timetables.findOne( - { where }, - { transaction }, - ); - - if (!timetables) { - return timetables; - } - - const output = timetables.get({plain: true}); - - - - - - - - - - - - - - - - - - output.timetable_periods_timetable = await timetables.getTimetable_periods_timetable({ - transaction - }); - - - - - - - - - - - - - - output.organization = await timetables.getOrganization({ - transaction - }); - - - output.campus = await timetables.getCampus({ - transaction - }); - - - output.academic_year = await timetables.getAcademic_year({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.organizations, - as: 'organization', - - }, - - { - model: db.campuses, - as: 'campus', - - where: filter.campus ? { - [Op.or]: [ - { id: { [Op.in]: filter.campus.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.campus.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.academic_years, - as: 'academic_year', - - where: filter.academic_year ? { - [Op.or]: [ - { id: { [Op.in]: filter.academic_year.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.academic_year.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'timetables', - 'name', - filter.name, - ), - }; - } - - - - - if (filter.calendarStart && filter.calendarEnd) { - where = { - ...where, - [Op.or]: [ - { - effective_from: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - { - effective_to: { - [Op.between]: [filter.calendarStart, filter.calendarEnd], - }, - }, - ], - }; - } - - - - if (filter.effective_fromRange) { - const [start, end] = filter.effective_fromRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - effective_from: { - ...where.effective_from, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - effective_from: { - ...where.effective_from, - [Op.lte]: end, - }, - }; - } - } - - if (filter.effective_toRange) { - const [start, end] = filter.effective_toRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - effective_to: { - ...where.effective_to, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - effective_to: { - ...where.effective_to, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.status) { - where = { - ...where, - status: filter.status, - }; - } - - - - - if (filter.organization) { - const listItems = filter.organization.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationId: {[Op.or]: listItems} - }; - } - - - - - - - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.timetables.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'timetables', - 'name', - query, - ), - ], - }; - } - - const records = await db.timetables.findAll({ - attributes: [ 'id', 'name' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['name', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.name, - })); - } - - -}; - diff --git a/backend/src/db/api/timetables.ts b/backend/src/db/api/timetables.ts new file mode 100644 index 0000000..f35a751 --- /dev/null +++ b/backend/src/db/api/timetables.ts @@ -0,0 +1,387 @@ +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import Utils from '@/db/utils'; +import type { Timetables } from '@/db/models/timetables'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +type TimetablesData = Partial> & { + organization?: string | null; + campus?: string | null; + academic_year?: string | null; +}; + +interface TimetablesFilter { + limit?: number | string; + page?: number | string; + id?: string; + name?: string; + calendarStart?: string; + calendarEnd?: string; + effective_fromRange?: Array; + effective_toRange?: Array; + active?: boolean | string; + status?: string; + campus?: string; + academic_year?: string; + organization?: string; + createdAtRange?: Array; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +class TimetablesDBApi { + static async create( + data: TimetablesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const timetables = await db.timetables.create( + { + id: data.id || undefined, + name: data.name || null, + effective_from: data.effective_from || null, + effective_to: data.effective_to || null, + status: data.status || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await timetables.setOrganization(currentUser.organizationId ?? undefined, { + transaction, + }); + await timetables.setCampus(data.campus ?? undefined, { transaction }); + await timetables.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + + return timetables; + } + + static async bulkImport( + data: TimetablesData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const timetablesData = data.map((item, index) => ({ + id: item.id || undefined, + name: item.name || null, + effective_from: item.effective_from || null, + effective_to: item.effective_to || null, + status: item.status || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + return db.timetables.bulkCreate(timetablesData, { transaction }); + } + + static async update( + id: string, + data: TimetablesData, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const globalAccess = currentUser.app_role?.globalAccess; + + const timetables = await db.timetables.findByPk(id, { transaction }); + + if (!timetables) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.effective_from !== undefined) + updatePayload.effective_from = data.effective_from; + if (data.effective_to !== undefined) + updatePayload.effective_to = data.effective_to; + if (data.status !== undefined) updatePayload.status = data.status; + + updatePayload.updatedById = currentUser.id; + + await timetables.update(updatePayload, { transaction }); + + if (data.organization !== undefined) { + const orgId = globalAccess + ? data.organization + : currentUser.organizationId; + await timetables.setOrganization(orgId ?? undefined, { transaction }); + } + if (data.campus !== undefined) { + await timetables.setCampus(data.campus ?? undefined, { transaction }); + } + if (data.academic_year !== undefined) { + await timetables.setAcademic_year(data.academic_year ?? undefined, { + transaction, + }); + } + + return timetables; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.timetables, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.timetables, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise | null> { + const transaction = options?.transaction; + + const timetables = await db.timetables.findOne({ where, transaction }); + + if (!timetables) { + return null; + } + + const output: Record = timetables.get({ plain: true }); + + const [timetable_periods_timetable, organization, campus, academic_year] = + await Promise.all([ + timetables.getTimetable_periods_timetable({ transaction }), + timetables.getOrganization({ transaction }), + timetables.getCampus({ transaction }), + timetables.getAcademic_year({ transaction }), + ]); + output.timetable_periods_timetable = timetable_periods_timetable; + output.organization = organization; + output.campus = campus; + output.academic_year = academic_year; + + return output; + } + + static async findAll( + filter: TimetablesFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Timetables[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + const include: Includeable[] = [ + { model: db.organizations, as: 'organization' }, + { + model: db.campuses, + as: 'campus', + where: filter.campus + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.campus.split('|').map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.campus + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { + model: db.academic_years, + as: 'academic_year', + where: filter.academic_year + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.academic_year + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.academic_year + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('timetables', 'name', filter.name), + }; + } + if (filter.calendarStart && filter.calendarEnd) { + where = { + ...where, + [Op.or]: [ + { + effective_from: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + { + effective_to: { + [Op.between]: [filter.calendarStart, filter.calendarEnd], + }, + }, + ], + }; + } + if (filter.effective_fromRange) { + const [start, end] = filter.effective_fromRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, effective_from: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + effective_from: { + ...(typeof where.effective_from === 'object' + ? where.effective_from + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.effective_toRange) { + const [start, end] = filter.effective_toRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, effective_to: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + effective_to: { + ...(typeof where.effective_to === 'object' + ? where.effective_to + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + if (filter.organization) { + const listItems = filter.organization + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.timetables.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.timetables, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } +} + +export default TimetablesDBApi; diff --git a/backend/src/db/api/types.ts b/backend/src/db/api/types.ts new file mode 100644 index 0000000..746f398 --- /dev/null +++ b/backend/src/db/api/types.ts @@ -0,0 +1,101 @@ +import type { InferAttributes, Transaction } from 'sequelize'; +import type { Users } from '@/db/models/users'; +import type { Staff } from '@/db/models/staff'; +import type { Roles } from '@/db/models/roles'; +import type { Permissions } from '@/db/models/permissions'; +import type { Organizations } from '@/db/models/organizations'; +import type { Campuses } from '@/db/models/campuses'; + +/** A permission record, reduced to the fields consumers read. */ +export interface PermissionLike { + name?: string | null; +} + +/** + * The trimmed user record returned by `UsersDBApi.findProfileById`, built for + * the `GET /me` profile in a single eager-loaded query (no per-association + * getter round-trips). Only the columns and relations the profile DTO reads + * are selected. + */ +export interface UserProfileRecord { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + organizationId: string | null; + organizations: Organizations | null; + app_role: Roles | null; + app_role_permissions: Permissions[]; + custom_permissions: Permissions[]; + staff_user: Staff[]; + staff_campus: Campuses | null; +} + +/** + * The full user record returned by `UsersDBApi.findBy`: the user's own + * attributes plus its eagerly-loaded associations. It is a superset of + * {@link CurrentUser}, so it is assignable to `req.currentUser` without a cast. + */ +export type AuthenticatedUser = InferAttributes & { + staff_user: Staff[]; + app_role: Roles | null; + app_role_permissions: Permissions[]; + custom_permissions: Permissions[]; + organizations: Organizations | null; +}; + +/** Minimal shape of the authenticated user passed through the data layer. */ +export interface CurrentUser { + id: string | null; + organizations?: { id: string | null } | null; + organizationId?: string | null; + app_role?: { + globalAccess?: boolean | null; + name?: string | null; + /** Present on the loaded role instance attached to the request. */ + getPermissions?: () => Promise; + } | null; + /** + * Present when the value is the full authenticated user record attached to + * the request (e.g. `req.currentUser`), which the auth and permission layers + * read. The data-layer methods only rely on `id`. + */ + password?: string | null; + custom_permissions?: PermissionLike[] | null; + firstName?: string | null; + lastName?: string | null; + email?: string | null; + campusId?: string | null; + campus?: { code?: string | null; name?: string | null } | null; + staff_user?: Array<{ + campusId?: string | null; + staff_type?: string | null; + campus?: { code?: string | null; name?: string | null } | null; + }> | null; +} + +/** Common options accepted by db/api methods. */ +export interface DbApiOptions { + currentUser?: CurrentUser; + transaction?: Transaction; + countOnly?: boolean; + ignoreDuplicates?: boolean; + validate?: boolean; +} + +/** Polymorphic file-attachment relation target. */ +export interface FileRelation { + belongsTo: string; + belongsToColumn: string; + belongsToId: string; +} + +/** A file as received from the client or already persisted. */ +export interface FileInput { + id?: string; + new?: boolean; + name?: string; + sizeInBytes?: number; + privateUrl?: string; + publicUrl?: string; +} diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js deleted file mode 100644 index c315be5..0000000 --- a/backend/src/db/api/users.js +++ /dev/null @@ -1,1017 +0,0 @@ - -const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); -const Utils = require('../utils'); - -const bcrypt = require('bcrypt'); -const config = require('../../config'); - - - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -module.exports = class UsersDBApi { - - static async create(data,globalAccess, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - - const users = await db.users.create( - { - id: data.data.id || undefined, - - firstName: data.data.firstName - || - null - , - - lastName: data.data.lastName - || - null - , - - phoneNumber: data.data.phoneNumber - || - null - , - - email: data.data.email - || - null - , - - disabled: data.data.disabled - || - false - - , - - password: data.data.password - || - null - , - - emailVerified: data.data.emailVerified - || - true - - , - - emailVerificationToken: data.data.emailVerificationToken - || - null - , - - emailVerificationTokenExpiresAt: data.data.emailVerificationTokenExpiresAt - || - null - , - - passwordResetToken: data.data.passwordResetToken - || - null - , - - passwordResetTokenExpiresAt: data.data.passwordResetTokenExpiresAt - || - null - , - - provider: data.data.provider - || - null - , - - importHash: data.data.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - - - if (!data.data.app_role) { - const role = await db.roles.findOne({ - where: { name: 'User' }, - }); - if (role) { - await users.setApp_role(role, { - transaction, - }); - } - }else{ - await users.setApp_role(data.data.app_role || null, { - transaction, - }); - } - - - - - await users.setOrganizations( data.data.organizations || null, { - transaction, - }); - - - - - await users.setCustom_permissions(data.data.custom_permissions || [], { - transaction, - }); - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.users.getTableName(), - belongsToColumn: 'avatar', - belongsToId: users.id, - }, - data.data.avatar, - options, - ); - - - return users; - } - - - - - static async bulkImport(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - // Prepare data - wrapping individual data transformations in a map() method - const usersData = data.map((item, index) => ({ - id: item.id || undefined, - - firstName: item.firstName - || - null - , - - lastName: item.lastName - || - null - , - - phoneNumber: item.phoneNumber - || - null - , - - email: item.email - || - null - , - - disabled: item.disabled - || - false - - , - - password: item.password - || - null - , - - emailVerified: item.emailVerified - || - false - - , - - emailVerificationToken: item.emailVerificationToken - || - null - , - - emailVerificationTokenExpiresAt: item.emailVerificationTokenExpiresAt - || - null - , - - passwordResetToken: item.passwordResetToken - || - null - , - - passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt - || - null - , - - provider: item.provider - || - null - , - - importHash: item.importHash || null, - createdById: currentUser.id, - updatedById: currentUser.id, - createdAt: new Date(Date.now() + index * 1000), - })); - - // Bulk create items - const users = await db.users.bulkCreate(usersData, { transaction }); - - // For each item created, replace relation files - - for (let i = 0; i < users.length; i++) { - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.users.getTableName(), - belongsToColumn: 'avatar', - belongsToId: users[i].id, - }, - data[i].avatar, - options, - ); - } - - - return users; - } - - static async update(id, data, globalAccess, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - - const users = await db.users.findByPk(id, {}, {transaction}); - - - - - if (!data?.app_role) { - data.app_role = users?.app_role?.id; - } - if (!data?.custom_permissions) { - data.custom_permissions = users?.custom_permissions?.map(item => item.id); - } - - if (data.password) { - data.password = bcrypt.hashSync( - data.password, - config.bcrypt.saltRounds, - ); - } else { - data.password = users.password; - } - - - const updatePayload = {}; - - if (data.firstName !== undefined) updatePayload.firstName = data.firstName; - - - if (data.lastName !== undefined) updatePayload.lastName = data.lastName; - - - if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber; - - - if (data.email !== undefined) updatePayload.email = data.email; - - - if (data.disabled !== undefined) updatePayload.disabled = data.disabled; - - - if (data.password !== undefined) updatePayload.password = data.password; - - - if (data.emailVerified !== undefined) updatePayload.emailVerified = data.emailVerified; - - else updatePayload.emailVerified = true; - - - if (data.emailVerificationToken !== undefined) updatePayload.emailVerificationToken = data.emailVerificationToken; - - - if (data.emailVerificationTokenExpiresAt !== undefined) updatePayload.emailVerificationTokenExpiresAt = data.emailVerificationTokenExpiresAt; - - - if (data.passwordResetToken !== undefined) updatePayload.passwordResetToken = data.passwordResetToken; - - - if (data.passwordResetTokenExpiresAt !== undefined) updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt; - - - if (data.provider !== undefined) updatePayload.provider = data.provider; - - - updatePayload.updatedById = currentUser.id; - - await users.update(updatePayload, {transaction}); - - - - if (data.app_role !== undefined) { - await users.setApp_role( - - data.app_role, - - { transaction } - ); - } - - if (data.organizations !== undefined) { - await users.setOrganizations( - - data.organizations, - - { transaction } - ); - } - - - - - if (data.custom_permissions !== undefined) { - await users.setCustom_permissions(data.custom_permissions, { transaction }); - } - - - - await FileDBApi.replaceRelationFiles( - { - belongsTo: db.users.getTableName(), - belongsToColumn: 'avatar', - belongsToId: users.id, - }, - data.avatar, - options, - ); - - - return users; - } - - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const users = await db.users.findAll({ - where: { - id: { - [Op.in]: ids, - }, - }, - transaction, - }); - - await db.sequelize.transaction(async (transaction) => { - for (const record of users) { - await record.update( - {deletedBy: currentUser.id}, - {transaction} - ); - } - for (const record of users) { - await record.destroy({transaction}); - } - }); - - - return users; - } - - static async remove(id, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - - const users = await db.users.findByPk(id, options); - - await users.update({ - deletedBy: currentUser.id - }, { - transaction, - }); - - await users.destroy({ - transaction - }); - - return users; - } - - static async findBy(where, options) { - const transaction = (options && options.transaction) || undefined; - - const users = await db.users.findOne( - { where }, - { transaction }, - ); - - if (!users) { - return users; - } - - const output = users.get({plain: true}); - - - - - - - - - - - - - output.staff_user = await users.getStaff_user({ - transaction - }); - - - - - - - - - - - - - - - output.messages_sent_by = await users.getMessages_sent_by({ - transaction - }); - - - - - - output.avatar = await users.getAvatar({ - transaction - }); - - - output.app_role = await users.getApp_role({ - transaction - }); - - if (output.app_role) { - output.app_role_permissions = await output.app_role.getPermissions({ - transaction, - }); - } - - - output.custom_permissions = await users.getCustom_permissions({ - transaction - }); - - - output.organizations = await users.getOrganizations({ - transaction - }); - - - - return output; - } - - static async findAll( - filter, - globalAccess, options - ) { - const limit = filter.limit || 0; - let offset = 0; - let where = {}; - const currentPage = +filter.page; - - - const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; - - - - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - - offset = currentPage * limit; - - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ - - { - model: db.roles, - as: 'app_role', - - where: filter.app_role ? { - [Op.or]: [ - { id: { [Op.in]: filter.app_role.split('|').map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: filter.app_role.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) - } - }, - ] - } : {}, - - }, - - { - model: db.organizations, - as: 'organizations', - - }, - - - { - model: db.permissions, - as: 'custom_permissions', - required: false, - }, - - - { - model: db.file, - as: 'avatar', - }, - - ]; - - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } - - - if (filter.firstName) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'firstName', - filter.firstName, - ), - }; - } - - if (filter.lastName) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'lastName', - filter.lastName, - ), - }; - } - - if (filter.phoneNumber) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'phoneNumber', - filter.phoneNumber, - ), - }; - } - - if (filter.email) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'email', - filter.email, - ), - }; - } - - if (filter.password) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'password', - filter.password, - ), - }; - } - - if (filter.emailVerificationToken) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'emailVerificationToken', - filter.emailVerificationToken, - ), - }; - } - - if (filter.passwordResetToken) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'passwordResetToken', - filter.passwordResetToken, - ), - }; - } - - if (filter.provider) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'users', - 'provider', - filter.provider, - ), - }; - } - - - - - - - if (filter.emailVerificationTokenExpiresAtRange) { - const [start, end] = filter.emailVerificationTokenExpiresAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - emailVerificationTokenExpiresAt: { - ...where.emailVerificationTokenExpiresAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - emailVerificationTokenExpiresAt: { - ...where.emailVerificationTokenExpiresAt, - [Op.lte]: end, - }, - }; - } - } - - if (filter.passwordResetTokenExpiresAtRange) { - const [start, end] = filter.passwordResetTokenExpiresAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - passwordResetTokenExpiresAt: { - ...where.passwordResetTokenExpiresAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - passwordResetTokenExpiresAt: { - ...where.passwordResetTokenExpiresAt, - [Op.lte]: end, - }, - }; - } - } - - - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } - - - if (filter.disabled) { - where = { - ...where, - disabled: filter.disabled, - }; - } - - if (filter.emailVerified) { - where = { - ...where, - emailVerified: filter.emailVerified, - }; - } - - - - - - - if (filter.organizations) { - const listItems = filter.organizations.split('|').map(item => { - return Utils.uuid(item) - }); - - where = { - ...where, - organizationsId: {[Op.or]: listItems} - }; - } - - - - if (filter.custom_permissions) { - const searchTerms = filter.custom_permissions.split('|'); - - include = [ - { - model: db.permissions, - as: 'custom_permissions_filter', - required: searchTerms.length > 0, - where: searchTerms.length > 0 ? { - [Op.or]: [ - { id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } }, - { - name: { - [Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` })) - } - } - ] - } : undefined - }, - ...include, - ] - } - - - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; - - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } - - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - - - - if (globalAccess) { - delete where.organizationsId; - } - - - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; - - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } - - try { - const { rows, count } = await db.users.findAndCountAll(queryOptions); - - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } - } - - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { - let where = {}; - - - if (!globalAccess && organizationId) { - where.organizationId = organizationId; - } - - - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'users', - 'firstName', - query, - ), - ], - }; - } - - const records = await db.users.findAll({ - attributes: [ 'id', 'firstName' ], - where, - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - orderBy: [['firstName', 'ASC']], - }); - - return records.map((record) => ({ - id: record.id, - label: record.firstName, - })); - } - - - static async createFromAuth(data, options) { - const transaction = (options && options.transaction) || undefined; - const users = await db.users.create( - { - email: data.email, - firstName: data.firstName, - authenticationUid: data.authenticationUid, - password: data.password, - - organizationId: data.organizationId, - - }, - { transaction }, - ); - - const app_role = await db.roles.findOne({ - where: { name: config.roles?.user || "User" }, - }); - if (app_role?.id) { - await users.setApp_role(app_role?.id || null, { - transaction, - }); - } - - await users.update( - { - authenticationUid: users.id, - }, - { transaction }, - ); - - delete users.password; - return users; - } - - static async updatePassword(id, password, options) { - const currentUser = (options && options.currentUser) || { id: null }; - - const transaction = (options && options.transaction) || undefined; - - const users = await db.users.findByPk(id, { - transaction, - }); - - await users.update( - { - password, - authenticationUid: id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - return users; - } - - static async generateEmailVerificationToken(email, options) { - return this._generateToken(['emailVerificationToken', 'emailVerificationTokenExpiresAt'], email, options); - } - - static async generatePasswordResetToken(email, options) { - return this._generateToken(['passwordResetToken', 'passwordResetTokenExpiresAt'], email, options); - } - - static async findByPasswordResetToken(token, options) { - const transaction = (options && options.transaction) || undefined; - - return db.users.findOne( - { - where: { - passwordResetToken: token, - passwordResetTokenExpiresAt: { - [db.Sequelize.Op.gt]: Date.now(), - }, - }, - }, - { transaction }, - ); - } - - static async findByEmailVerificationToken( - token, - options, - ) { - const transaction = (options && options.transaction) || undefined; - return db.users.findOne( - { - where: { - emailVerificationToken: token, - emailVerificationTokenExpiresAt: { - [db.Sequelize.Op.gt]: Date.now(), - }, - }, - }, - { transaction }, - ); - } - - static async markEmailVerified(id, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; - - const users = await db.users.findByPk(id, { - transaction, - }); - - await users.update( - { - emailVerified: true, - updatedById: currentUser.id, - }, - { transaction }, - ); - - return true; - } - - static async _generateToken(keyNames, email, options) { - const currentUser = (options && options.currentUser) || {id: null}; - const transaction = (options && options.transaction) || undefined; - const users = await db.users.findOne( - { - where: { email: email.toLowerCase() }, - }, - { - transaction, - }, - ); - - const token = crypto - .randomBytes(20) - .toString('hex'); - const tokenExpiresAt = Date.now() + 360000; - - if(users){ - await users.update( - { - [keyNames[0]]: token, - [keyNames[1]]: tokenExpiresAt, - updatedById: currentUser.id, - }, - {transaction}, - ); - } - - - return token; - } - - - -}; - diff --git a/backend/src/db/api/users.ts b/backend/src/db/api/users.ts new file mode 100644 index 0000000..bf470e1 --- /dev/null +++ b/backend/src/db/api/users.ts @@ -0,0 +1,814 @@ +import crypto from 'crypto'; +import { + Op, + type Includeable, + type InferAttributes, + type InferCreationAttributes, + type WhereAttributeHash, +} from 'sequelize'; +import db from '@/db/models'; +import { + removeRecord, + deleteRecordsByIds, + autocompleteByField, +} from '@/db/api/shared/repository'; +import { BULK_IMPORT_TIMESTAMP_STEP_MS } from '@/shared/constants/database'; +import { resolvePagination } from '@/shared/constants/pagination'; +import { SPECIAL_ROLE_NAMES } from '@/shared/constants/roles'; +import { + EMAIL_ACTION_TOKEN_BYTES, + EMAIL_ACTION_TOKEN_TTL_MS, +} from '@/shared/constants/auth'; +import Utils from '@/db/utils'; +import config from '@/shared/config'; +import FileDBApi from '@/db/api/file'; +import ValidationError from '@/shared/errors/validation'; +import type { Users } from '@/db/models/users'; +import type { + AuthenticatedUser, + CurrentUser, + DbApiOptions, + FileInput, + UserProfileRecord, +} from '@/db/api/types'; + +type UsersInputData = Partial> & { + app_role?: string | null; + organizations?: string | null; + custom_permissions?: string[]; + avatar?: FileInput | FileInput[] | null; +}; + +interface CreateFromAuthData { + email: string; + firstName?: string | null; + password?: string | null; + organizationId?: string | null; +} + +type DateRange = Array; + +interface UsersFilter { + limit?: number | string; + page?: number | string; + id?: string; + firstName?: string; + lastName?: string; + phoneNumber?: string; + email?: string; + password?: string; + emailVerificationToken?: string; + passwordResetToken?: string; + provider?: string; + emailVerificationTokenExpiresAtRange?: DateRange; + passwordResetTokenExpiresAtRange?: DateRange; + active?: boolean | string; + disabled?: boolean | string; + emailVerified?: boolean | string; + app_role?: string; + organizations?: string; + custom_permissions?: string; + createdAtRange?: DateRange; + field?: string; + sort?: string; +} + +const NO_USER: CurrentUser = { id: null }; + +function usersTableName(): string { + const name = db.users.getTableName(); + return typeof name === 'string' ? name : name.tableName; +} + +/** Email is a user's login and primary contact, so it is always required. */ +function requireEmail(email: string | null | undefined): string { + if (!email) { + throw new ValidationError('iam.errors.emailRequired'); + } + return email; +} + +class UsersDBApi { + static async create( + payload: { data: UsersInputData }, + _globalAccess: boolean, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + const data = payload.data; + + const users = await db.users.create( + { + id: data.id || undefined, + firstName: data.firstName || null, + lastName: data.lastName || null, + phoneNumber: data.phoneNumber || null, + email: requireEmail(data.email), + disabled: data.disabled || false, + password: data.password || null, + emailVerified: data.emailVerified || true, + emailVerificationToken: data.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + data.emailVerificationTokenExpiresAt || null, + passwordResetToken: data.passwordResetToken || null, + passwordResetTokenExpiresAt: data.passwordResetTokenExpiresAt || null, + provider: data.provider || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + if (!data.app_role) { + const role = await db.roles.findOne({ + where: { name: SPECIAL_ROLE_NAMES.DEFAULT_USER }, + }); + if (role) { + await users.setApp_role(role, { transaction }); + } + } else { + await users.setApp_role(data.app_role, { transaction }); + } + + await users.setOrganizations(data.organizations ?? undefined, { + transaction, + }); + await users.setCustom_permissions(data.custom_permissions || [], { + transaction, + }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: usersTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.avatar, + options, + ); + + return users; + } + + static async bulkImport( + data: UsersInputData[], + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const usersData = data.map((item, index) => ({ + id: item.id || undefined, + firstName: item.firstName || null, + lastName: item.lastName || null, + phoneNumber: item.phoneNumber || null, + email: requireEmail(item.email), + disabled: item.disabled || false, + password: item.password || null, + emailVerified: item.emailVerified || false, + emailVerificationToken: item.emailVerificationToken || null, + emailVerificationTokenExpiresAt: + item.emailVerificationTokenExpiresAt || null, + passwordResetToken: item.passwordResetToken || null, + passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null, + provider: item.provider || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * BULK_IMPORT_TIMESTAMP_STEP_MS), + })); + + const users = await db.users.bulkCreate(usersData, { transaction }); + + for (let i = 0; i < users.length; i++) { + await FileDBApi.replaceRelationFiles( + { + belongsTo: usersTableName(), + belongsToColumn: 'avatar', + belongsToId: users[i].id, + }, + data[i].avatar, + options, + ); + } + + return users; + } + + static async update( + id: string, + data: UsersInputData, + _globalAccess: boolean, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const users = await db.users.findByPk(id, { transaction }); + + if (!users) { + return null; + } + + const updatePayload: Partial> = {}; + + if (data.firstName !== undefined) updatePayload.firstName = data.firstName; + if (data.lastName !== undefined) updatePayload.lastName = data.lastName; + if (data.phoneNumber !== undefined) + updatePayload.phoneNumber = data.phoneNumber; + if (data.email !== undefined) updatePayload.email = data.email; + if (data.disabled !== undefined) updatePayload.disabled = data.disabled; + if (data.password !== undefined) updatePayload.password = data.password; + if (data.emailVerified !== undefined) + updatePayload.emailVerified = data.emailVerified; + if (data.emailVerificationToken !== undefined) + updatePayload.emailVerificationToken = data.emailVerificationToken; + if (data.emailVerificationTokenExpiresAt !== undefined) + updatePayload.emailVerificationTokenExpiresAt = + data.emailVerificationTokenExpiresAt; + if (data.passwordResetToken !== undefined) + updatePayload.passwordResetToken = data.passwordResetToken; + if (data.passwordResetTokenExpiresAt !== undefined) + updatePayload.passwordResetTokenExpiresAt = + data.passwordResetTokenExpiresAt; + if (data.provider !== undefined) updatePayload.provider = data.provider; + + updatePayload.updatedById = currentUser.id; + + await users.update(updatePayload, { transaction }); + + if (data.app_role !== undefined) { + await users.setApp_role(data.app_role ?? undefined, { transaction }); + } + if (data.organizations !== undefined) { + await users.setOrganizations(data.organizations ?? undefined, { + transaction, + }); + } + if (data.custom_permissions !== undefined) { + await users.setCustom_permissions(data.custom_permissions, { + transaction, + }); + } + + await FileDBApi.replaceRelationFiles( + { + belongsTo: usersTableName(), + belongsToColumn: 'avatar', + belongsToId: users.id, + }, + data.avatar, + options, + ); + + return users; + } + + static async deleteByIds( + ids: string[], + options?: DbApiOptions, + ): Promise { + return deleteRecordsByIds(db.users, ids, options); + } + + static async remove( + id: string, + options?: DbApiOptions, + ): Promise { + return removeRecord(db.users, id, options); + } + + static async findBy( + where: WhereAttributeHash, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + // Per-request auth/session load. Authorization needs only role (+its + // permissions), per-user permissions, staff profile, and org — loaded in a + // single eager query (no per-association getter round-trips). `app_role` + // carries its `permissions`, so the permission middleware reads them off + // the loaded array instead of issuing another `getPermissions()` query. + // `messages_sent_by` (unbounded) and `avatar` are intentionally skipped. + const users = await db.users.findOne({ + where, + include: [ + { + model: db.roles, + as: 'app_role', + include: [ + { + model: db.permissions, + as: 'permissions', + through: { attributes: [] }, + }, + ], + }, + { model: db.staff, as: 'staff_user' }, + { + model: db.permissions, + as: 'custom_permissions', + through: { attributes: [] }, + }, + { model: db.organizations, as: 'organizations' }, + ], + transaction, + }); + + if (!users) { + return null; + } + + return { + ...users.get({ plain: true }), + staff_user: users.staff_user ?? [], + app_role: users.app_role ?? null, + app_role_permissions: users.app_role?.permissions ?? [], + custom_permissions: users.custom_permissions ?? [], + organizations: users.organizations ?? null, + }; + } + + /** + * Trimmed profile fetch for `GET /me` (and the signin/refresh responses): + * one eager-loaded query selecting only the columns and relations the + * profile DTO reads — no per-association getter round-trips, no `getCampus`. + */ + static async findProfileById( + id: string, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + const user = await db.users.findByPk(id, { + // `app_roleId`/`organizationId` are the belongsTo foreign keys needed to + // join `app_role`/`organizations`; they are not returned in the DTO. + attributes: [ + 'id', + 'email', + 'firstName', + 'lastName', + 'organizationId', + 'app_roleId', + ], + include: [ + { + model: db.roles, + as: 'app_role', + attributes: ['id', 'name', 'globalAccess'], + include: [ + { + model: db.permissions, + as: 'permissions', + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }, + { + model: db.permissions, + as: 'custom_permissions', + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + { + model: db.organizations, + as: 'organizations', + attributes: ['id', 'name'], + }, + { + model: db.staff, + as: 'staff_user', + attributes: [ + 'id', + 'employee_number', + 'job_title', + 'staff_type', + 'status', + 'organizationId', + 'campusId', + 'userId', + ], + include: [ + { + model: db.campuses, + as: 'campus', + attributes: ['id', 'name', 'code'], + }, + ], + }, + ], + transaction, + }); + + if (!user) { + return null; + } + + const staffProfile = user.staff_user?.[0] ?? null; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + organizationId: user.organizationId, + organizations: user.organizations ?? null, + app_role: user.app_role ?? null, + app_role_permissions: user.app_role?.permissions ?? [], + custom_permissions: user.custom_permissions ?? [], + staff_user: user.staff_user ?? [], + staff_campus: staffProfile?.campus ?? null, + }; + } + + static async findAll( + filter: UsersFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Users[]; count: number }> { + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + let where: WhereAttributeHash = {}; + + const userOrganizations = options?.currentUser?.organizations?.id ?? null; + if (userOrganizations && options?.currentUser?.organizationId) { + where.organizationId = options.currentUser.organizationId; + } + + let include: Includeable[] = [ + { + model: db.roles, + as: 'app_role', + where: filter.app_role + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.app_role + .split('|') + .map((t) => Utils.uuid(t)), + }, + }, + { + name: { + [Op.or]: filter.app_role + .split('|') + .map((t) => ({ [Op.iLike]: `%${t}%` })), + }, + }, + ], + } + : {}, + }, + { model: db.organizations, as: 'organizations' }, + { model: db.permissions, as: 'custom_permissions', required: false }, + { model: db.file, as: 'avatar' }, + ]; + + if (filter.id) { + where = { ...where, id: Utils.uuid(filter.id) }; + } + if (filter.firstName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'firstName', filter.firstName), + }; + } + if (filter.lastName) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'lastName', filter.lastName), + }; + } + if (filter.phoneNumber) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber), + }; + } + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'email', filter.email), + }; + } + if (filter.password) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'password', filter.password), + }; + } + if (filter.emailVerificationToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'emailVerificationToken', + filter.emailVerificationToken, + ), + }; + } + if (filter.passwordResetToken) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'users', + 'passwordResetToken', + filter.passwordResetToken, + ), + }; + } + if (filter.provider) { + where = { + ...where, + [Op.and]: Utils.ilike('users', 'provider', filter.provider), + }; + } + if (filter.emailVerificationTokenExpiresAtRange) { + const [start, end] = filter.emailVerificationTokenExpiresAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { [Op.gte]: start }, + }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + emailVerificationTokenExpiresAt: { + ...(typeof where.emailVerificationTokenExpiresAt === 'object' + ? where.emailVerificationTokenExpiresAt + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.passwordResetTokenExpiresAtRange) { + const [start, end] = filter.passwordResetTokenExpiresAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, passwordResetTokenExpiresAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + passwordResetTokenExpiresAt: { + ...(typeof where.passwordResetTokenExpiresAt === 'object' + ? where.passwordResetTokenExpiresAt + : {}), + [Op.lte]: end, + }, + }; + } + } + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + if (filter.disabled) { + where = { ...where, disabled: filter.disabled }; + } + if (filter.emailVerified) { + where = { ...where, emailVerified: filter.emailVerified }; + } + if (filter.organizations) { + const listItems = filter.organizations + .split('|') + .map((item) => Utils.uuid(item)); + where = { ...where, organizationId: { [Op.or]: listItems } }; + } + if (filter.custom_permissions) { + const searchTerms = filter.custom_permissions.split('|'); + include = [ + { + model: db.permissions, + as: 'custom_permissions_filter', + required: searchTerms.length > 0, + where: + searchTerms.length > 0 + ? { + [Op.or]: [ + { id: { [Op.in]: searchTerms.map((t) => Utils.uuid(t)) } }, + { + name: { + [Op.or]: searchTerms.map((t) => ({ + [Op.iLike]: `%${t}%`, + })), + }, + }, + ], + } + : undefined, + }, + ...include, + ]; + } + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + if (start !== undefined && start !== null && start !== '') { + where = { ...where, createdAt: { [Op.gte]: start } }; + } + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...(typeof where.createdAt === 'object' ? where.createdAt : {}), + [Op.lte]: end, + }, + }; + } + } + + if (globalAccess) { + delete where.organizationId; + } + + const order: [string, string][] = + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']]; + + const { rows, count } = await db.users.findAndCountAll({ + where, + include, + distinct: true, + order, + transaction: options?.transaction, + limit: !options?.countOnly && limit ? limit : undefined, + offset: !options?.countOnly && offset ? offset : undefined, + }); + + return { rows: options?.countOnly ? [] : rows, count }; + } + + static async findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId: string | undefined, + ): Promise> { + return autocompleteByField( + db.users, + 'name', + query, + limit, + offset, + globalAccess, + organizationId, + ); + } + + static async createFromAuth( + data: CreateFromAuthData, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + const users = await db.users.create( + { + email: data.email, + firstName: data.firstName, + password: data.password, + organizationId: data.organizationId, + }, + { transaction }, + ); + + const app_role = await db.roles.findOne({ + where: { name: config.roles.user || 'User' }, + }); + if (app_role?.id) { + await users.setApp_role(app_role.id, { transaction }); + } + + return users; + } + + static async updatePassword( + id: string, + password: string, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const users = await db.users.findByPk(id, { transaction }); + + if (!users) { + return null; + } + + await users.update( + { password, updatedById: currentUser.id }, + { transaction }, + ); + + return users; + } + + static async generateEmailVerificationToken( + email: string, + options?: DbApiOptions, + ): Promise { + return this._generateToken('emailVerification', email, options); + } + + static async generatePasswordResetToken( + email: string, + options?: DbApiOptions, + ): Promise { + return this._generateToken('passwordReset', email, options); + } + + static async findByPasswordResetToken( + token: string, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + return db.users.findOne({ + where: { + passwordResetToken: token, + passwordResetTokenExpiresAt: { [Op.gt]: new Date() }, + }, + transaction, + }); + } + + static async findByEmailVerificationToken( + token: string, + options?: DbApiOptions, + ): Promise { + const transaction = options?.transaction; + + return db.users.findOne({ + where: { + emailVerificationToken: token, + emailVerificationTokenExpiresAt: { [Op.gt]: new Date() }, + }, + transaction, + }); + } + + static async markEmailVerified( + id: string, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const users = await db.users.findByPk(id, { transaction }); + + if (!users) { + return false; + } + + await users.update( + { emailVerified: true, updatedById: currentUser.id }, + { transaction }, + ); + + return true; + } + + private static async _generateToken( + kind: 'emailVerification' | 'passwordReset', + email: string, + options?: DbApiOptions, + ): Promise { + const currentUser = options?.currentUser ?? NO_USER; + const transaction = options?.transaction; + + const users = await db.users.findOne({ + where: { email: email.toLowerCase() }, + transaction, + }); + + const token = crypto.randomBytes(EMAIL_ACTION_TOKEN_BYTES).toString('hex'); + const tokenExpiresAt = new Date(Date.now() + EMAIL_ACTION_TOKEN_TTL_MS); + + if (users) { + const payload: Partial> = { + updatedById: currentUser.id, + }; + if (kind === 'emailVerification') { + payload.emailVerificationToken = token; + payload.emailVerificationTokenExpiresAt = tokenExpiresAt; + } else { + payload.passwordResetToken = token; + payload.passwordResetTokenExpiresAt = tokenExpiresAt; + } + await users.update(payload, { transaction }); + } + + return token; + } +} + +export default UsersDBApi; diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js deleted file mode 100644 index 997df4d..0000000 --- a/backend/src/db/db.config.js +++ /dev/null @@ -1,40 +0,0 @@ -require('../config/load-env'); - -const { - DEFAULT_DEV_DB_HOST, - DEFAULT_DEV_DB_NAME, - DEFAULT_DEV_DB_USER, -} = require('../constants/app'); - -module.exports = { - production: { - dialect: 'postgres', - username: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, - host: process.env.DB_HOST, - port: process.env.DB_PORT, - logging: console.log, - seederStorage: 'sequelize', - }, - development: { - username: process.env.DB_USER || DEFAULT_DEV_DB_USER, - dialect: 'postgres', - password: process.env.DB_PASS || '', - database: process.env.DB_NAME || DEFAULT_DEV_DB_NAME, - host: process.env.DB_HOST || DEFAULT_DEV_DB_HOST, - port: process.env.DB_PORT, - logging: console.log, - seederStorage: 'sequelize', - }, - dev_stage: { - dialect: 'postgres', - username: process.env.DB_USER, - password: process.env.DB_PASS, - database: process.env.DB_NAME, - host: process.env.DB_HOST, - port: process.env.DB_PORT, - logging: console.log, - seederStorage: 'sequelize', - } -}; diff --git a/backend/src/db/db.config.ts b/backend/src/db/db.config.ts new file mode 100644 index 0000000..e49f481 --- /dev/null +++ b/backend/src/db/db.config.ts @@ -0,0 +1,42 @@ +import '@/shared/config/load-env'; + +interface ConnectionConfig { + username?: string; + password?: string; + database?: string; + host?: string; + port?: string; +} + +/** + * Database connection parameters per environment. Defaults for development are + * applied by the Sequelize bootstrap in `db/models/index.ts`. + */ +const dbConfig: Record< + 'production' | 'development' | 'dev_stage', + ConnectionConfig +> = { + production: { + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + }, + development: { + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + }, + dev_stage: { + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + }, +}; + +export default dbConfig; diff --git a/backend/src/db/initial-schema.ts b/backend/src/db/initial-schema.ts new file mode 100644 index 0000000..b376c94 --- /dev/null +++ b/backend/src/db/initial-schema.ts @@ -0,0 +1,143 @@ +// AUTO-GENERATED schema snapshot from the Sequelize models. +// Source for the initial migration (see migrations/*-initial-schema.ts). +export const INITIAL_SCHEMA_UP = ` +CREATE TABLE IF NOT EXISTS "users" ("id" UUID , "firstName" TEXT, "lastName" TEXT, "phoneNumber" TEXT, "email" TEXT NOT NULL, "disabled" BOOLEAN NOT NULL DEFAULT false, "password" TEXT, "emailVerified" BOOLEAN NOT NULL DEFAULT false, "emailVerificationToken" TEXT, "emailVerificationTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "passwordResetToken" TEXT, "passwordResetTokenExpiresAt" TIMESTAMP WITH TIME ZONE, "provider" TEXT, "importHash" VARCHAR(255) UNIQUE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "app_roleId" UUID, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "academic_years" ("id" UUID , "name" TEXT, "start_date" TIMESTAMP WITH TIME ZONE, "end_date" TIMESTAMP WITH TIME ZONE, "current" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_assessment_results_grade_letter" AS ENUM(''A'', ''B'', ''C'', ''D'', ''E'', ''F'', ''P'', ''N''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "assessment_results" ("id" UUID , "score" DECIMAL, "grade_letter" "public"."enum_assessment_results_grade_letter", "remarks" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "assessmentId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_assessments_assessment_type" AS ENUM(''quiz'', ''homework'', ''project'', ''midterm'', ''final'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_assessments_status" AS ENUM(''draft'', ''published'', ''closed''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "assessments" ("id" UUID , "name" TEXT, "assessment_type" "public"."enum_assessments_assessment_type", "assigned_at" TIMESTAMP WITH TIME ZONE, "due_at" TIMESTAMP WITH TIME ZONE, "max_score" DECIMAL, "status" "public"."enum_assessments_status", "instructions" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "class_subjectId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_attendance_records_status" AS ENUM(''present'', ''absent'', ''late'', ''excused''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "attendance_records" ("id" UUID , "status" "public"."enum_attendance_records_status", "minutes_late" INTEGER, "remarks" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "attendance_sessionId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_attendance_sessions_session_type" AS ENUM(''homeroom'', ''subject'', ''exam'', ''event''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "attendance_sessions" ("id" UUID , "session_date" TIMESTAMP WITH TIME ZONE, "session_type" "public"."enum_attendance_sessions_session_type", "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "classId" UUID, "class_subjectId" UUID, "taken_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" ("id" UUID , "userId" UUID NOT NULL, "organizationId" UUID, "tokenHash" TEXT NOT NULL UNIQUE, "familyId" UUID NOT NULL, "previousTokenId" UUID, "userAgent" TEXT, "ipAddress" TEXT, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "revokedAt" TIMESTAMP WITH TIME ZONE, "replacedByTokenId" UUID, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "campus_attendance_config" ("id" UUID , "campus_key" TEXT NOT NULL, "attendance_link" TEXT, "updated_by_label" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "campus_attendance_summaries" ("id" UUID , "campus_key" TEXT NOT NULL, "attendance_date" DATE NOT NULL, "total_enrolled" INTEGER NOT NULL, "total_present" INTEGER NOT NULL, "total_absent" INTEGER NOT NULL, "total_tardy" INTEGER NOT NULL DEFAULT 0, "attendance_percentage" DECIMAL(5,2) NOT NULL, "recorded_by_label" TEXT, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "campuses" ("id" UUID , "name" TEXT NOT NULL, "code" TEXT NOT NULL, "address" TEXT, "phone" TEXT, "email" TEXT, "mascot" TEXT, "color" TEXT, "bgGradient" TEXT, "borderColor" TEXT, "textColor" TEXT, "bgLight" TEXT, "description" TEXT, "isOnline" BOOLEAN NOT NULL DEFAULT false, "active" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_class_enrollments_status" AS ENUM(''active'', ''dropped'', ''completed''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "class_enrollments" ("id" UUID , "enrolled_on" TIMESTAMP WITH TIME ZONE, "ended_on" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_class_enrollments_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "classId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_class_subjects_status" AS ENUM(''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "class_subjects" ("id" UUID , "status" "public"."enum_class_subjects_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "classId" UUID, "subjectId" UUID, "teacherId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_classes_status" AS ENUM(''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "classes" ("id" UUID , "name" TEXT, "section" TEXT, "capacity" INTEGER, "status" "public"."enum_classes_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "gradeId" UUID, "homeroom_teacherId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_communication_events_event_type" AS ENUM(''meeting'', ''drill'', ''event'', ''deadline''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "communication_events" ("id" UUID , "title" TEXT NOT NULL, "event_date" DATE NOT NULL, "event_type" "public"."enum_communication_events_event_type" NOT NULL, "roles" JSONB NOT NULL DEFAULT '[]', "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "content_catalog" ("id" UUID , "content_type" TEXT NOT NULL UNIQUE, "payload" JSONB NOT NULL, "active" BOOLEAN NOT NULL DEFAULT true, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_documents_entity_type" AS ENUM(''student'', ''staff'', ''class'', ''invoice'', ''organization'', ''campus'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_documents_category" AS ENUM(''policy'', ''report'', ''id'', ''medical'', ''consent'', ''invoice'', ''receipt'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "documents" ("id" UUID , "entity_type" "public"."enum_documents_entity_type", "entity_reference" TEXT, "name" TEXT, "category" "public"."enum_documents_category", "uploaded_at" TIMESTAMP WITH TIME ZONE, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_fee_plans_billing_cycle" AS ENUM(''one_time'', ''monthly'', ''termly'', ''annual''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "fee_plans" ("id" UUID , "name" TEXT, "billing_cycle" "public"."enum_fee_plans_billing_cycle", "total_amount" DECIMAL, "active" BOOLEAN NOT NULL DEFAULT false, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "organizationId" UUID, "gradeId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "files" ("id" UUID , "belongsTo" VARCHAR(255), "belongsToId" UUID, "belongsToColumn" VARCHAR(255), "name" VARCHAR(2083) NOT NULL, "sizeInBytes" INTEGER, "privateUrl" VARCHAR(2083), "publicUrl" VARCHAR(2083) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "frame_entries" ("id" UUID , "week_of" TEXT NOT NULL, "posted_date" TEXT NOT NULL, "formal" TEXT NOT NULL, "recognition" TEXT NOT NULL, "application" TEXT NOT NULL, "management" TEXT NOT NULL, "emotional" TEXT NOT NULL, "author" TEXT NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "grades" ("id" UUID , "name" TEXT, "code" TEXT, "sort_order" INTEGER, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_guardians_relationship" AS ENUM(''mother'', ''father'', ''guardian'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "guardians" ("id" UUID , "full_name" TEXT, "relationship" "public"."enum_guardians_relationship", "phone" TEXT, "email" TEXT, "address" TEXT, "primary_contact" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_invoices_status" AS ENUM(''draft'', ''issued'', ''partially_paid'', ''paid'', ''overdue'', ''void''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "invoices" ("id" UUID , "invoice_number" TEXT, "issue_date" TIMESTAMP WITH TIME ZONE, "due_date" TIMESTAMP WITH TIME ZONE, "subtotal" DECIMAL, "discount_amount" DECIMAL, "tax_amount" DECIMAL, "total_amount" DECIMAL, "balance_due" DECIMAL, "status" "public"."enum_invoices_status", "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "fee_planId" UUID, "organizationId" UUID, "studentId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_recipient_type" AS ENUM(''user'', ''student'', ''guardian''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_message_recipients_delivery_status" AS ENUM(''pending'', ''sent'', ''delivered'', ''failed'', ''read''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "message_recipients" ("id" UUID , "recipient_type" "public"."enum_message_recipients_recipient_type", "recipient_label" TEXT, "destination" TEXT, "delivery_status" "public"."enum_message_recipients_delivery_status", "delivered_at" TIMESTAMP WITH TIME ZONE, "read_at" TIMESTAMP WITH TIME ZONE, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "messageId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_messages_channel" AS ENUM(''in_app'', ''email'', ''sms''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_messages_audience" AS ENUM(''all_org'', ''campus'', ''class'', ''staff'', ''students'', ''guardians'', ''custom''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_messages_status" AS ENUM(''draft'', ''scheduled'', ''sent'', ''failed''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "messages" ("id" UUID , "subject" TEXT, "body" TEXT, "channel" "public"."enum_messages_channel", "audience" "public"."enum_messages_audience", "sent_at" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_messages_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "sent_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "organizations" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_payments_method" AS ENUM(''cash'', ''bank_transfer'', ''card'', ''mobile_money'', ''cheque'', ''other''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "payments" ("id" UUID , "receipt_number" TEXT, "paid_at" TIMESTAMP WITH TIME ZONE, "amount" DECIMAL, "method" "public"."enum_payments_method", "reference_code" TEXT, "notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "invoiceId" UUID, "organizationId" UUID, "received_byId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "permissions" ("id" UUID , "name" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "personality_quiz_results" ("id" UUID , "personality_type" TEXT NOT NULL, "quiz_answers" JSONB NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "roles" ("id" UUID , "name" TEXT, "globalAccess" BOOLEAN NOT NULL DEFAULT false, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "safety_quiz_results" ("id" UUID , "quiz_id" TEXT NOT NULL, "quiz_title" TEXT NOT NULL, "week_of" TEXT NOT NULL, "score" INTEGER NOT NULL, "total_questions" INTEGER NOT NULL, "answers" JSONB NOT NULL, "user_name" TEXT NOT NULL, "user_role" TEXT NOT NULL, "completed_at" TIMESTAMP WITH TIME ZONE NOT NULL, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "campusId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_staff_staff_type" AS ENUM(''teacher'', ''admin'', ''support''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_staff_status" AS ENUM(''active'', ''on_leave'', ''inactive''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "staff" ("id" UUID , "employee_number" TEXT, "job_title" TEXT, "staff_type" "public"."enum_staff_staff_type", "hire_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_staff_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "userId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_staff_attendance_records_status" AS ENUM(''present'', ''late'', ''absent''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "staff_attendance_records" ("id" UUID , "attendance_date" DATE NOT NULL, "status" "public"."enum_staff_attendance_records_status" NOT NULL, "note" TEXT, "user_name" TEXT NOT NULL, "user_role" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_students_gender" AS ENUM(''male'', ''female'', ''other'', ''prefer_not_to_say''); EXCEPTION WHEN duplicate_object THEN null; END'; +DO 'BEGIN CREATE TYPE "public"."enum_students_status" AS ENUM(''prospect'', ''enrolled'', ''inactive'', ''graduated'', ''transferred''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "students" ("id" UUID , "student_number" TEXT, "first_name" TEXT, "last_name" TEXT, "gender" "public"."enum_students_gender", "date_of_birth" TIMESTAMP WITH TIME ZONE, "enrollment_date" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_students_status", "email" TEXT, "phone" TEXT, "address" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "subjects" ("id" UUID , "name" TEXT, "code" TEXT, "description" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_timetable_periods_day_of_week" AS ENUM(''monday'', ''tuesday'', ''wednesday'', ''thursday'', ''friday'', ''saturday'', ''sunday''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "timetable_periods" ("id" UUID , "day_of_week" "public"."enum_timetable_periods_day_of_week", "starts_at" TIMESTAMP WITH TIME ZONE, "ends_at" TIMESTAMP WITH TIME ZONE, "room" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "class_subjectId" UUID, "organizationId" UUID, "timetableId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_timetables_status" AS ENUM(''draft'', ''active'', ''archived''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "timetables" ("id" UUID , "name" TEXT, "effective_from" TIMESTAMP WITH TIME ZONE, "effective_to" TIMESTAMP WITH TIME ZONE, "status" "public"."enum_timetables_status", "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "academic_yearId" UUID, "campusId" UUID, "organizationId" UUID, "createdById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +DO 'BEGIN CREATE TYPE "public"."enum_user_progress_progress_type" AS ENUM(''sign_learned'', ''zone_checkin''); EXCEPTION WHEN duplicate_object THEN null; END'; +CREATE TABLE IF NOT EXISTS "user_progress" ("id" UUID , "progress_type" "public"."enum_user_progress_progress_type" NOT NULL, "item_id" TEXT NOT NULL, "value" TEXT, "score" INTEGER, "metadata" JSONB, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "userId" UUID NOT NULL, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "walkthrough_checkins" ("id" UUID , "teacher_name" TEXT NOT NULL, "classroom" TEXT NOT NULL, "director_name" TEXT NOT NULL, "check_in_date" DATE NOT NULL, "check_in_time" TIME NOT NULL, "attitude_rating" INTEGER NOT NULL, "attitude_comment" TEXT, "classroom_management_rating" INTEGER NOT NULL, "classroom_management_comment" TEXT, "cleanliness_rating" INTEGER NOT NULL, "cleanliness_comment" TEXT, "vibes_rating" INTEGER NOT NULL, "vibes_comment" TEXT, "team_dynamics_rating" INTEGER NOT NULL, "team_dynamics_comment" TEXT, "emergency_exit_rating" INTEGER NOT NULL, "emergency_exit_comment" TEXT, "lesson_plan_rating" INTEGER NOT NULL, "lesson_plan_comment" TEXT, "overall_notes" TEXT, "importHash" VARCHAR(255) UNIQUE, "createdAt" TIMESTAMP WITH TIME ZONE, "updatedAt" TIMESTAMP WITH TIME ZONE, "deletedAt" TIMESTAMP WITH TIME ZONE, "organizationId" UUID NOT NULL, "campusId" UUID, "createdById" UUID NOT NULL REFERENCES "users" ("id") ON DELETE NO ACTION ON UPDATE CASCADE, "updatedById" UUID REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY ("id")); +CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "roles_permissionsId" UUID , "permissionId" UUID , PRIMARY KEY ("roles_permissionsId","permissionId")); +CREATE TABLE IF NOT EXISTS "usersCustom_permissionsPermissions" ("createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "users_custom_permissionsId" UUID , "permissionId" UUID , PRIMARY KEY ("users_custom_permissionsId","permissionId")); +`; + +export const INITIAL_SCHEMA_DOWN = ` +DROP TABLE IF EXISTS "usersCustom_permissionsPermissions" CASCADE; +DROP TABLE IF EXISTS "rolesPermissionsPermissions" CASCADE; +DROP TABLE IF EXISTS "walkthrough_checkins" CASCADE; +DROP TABLE IF EXISTS "user_progress" CASCADE; +DROP TABLE IF EXISTS "timetables" CASCADE; +DROP TABLE IF EXISTS "timetable_periods" CASCADE; +DROP TABLE IF EXISTS "subjects" CASCADE; +DROP TABLE IF EXISTS "students" CASCADE; +DROP TABLE IF EXISTS "staff_attendance_records" CASCADE; +DROP TABLE IF EXISTS "staff" CASCADE; +DROP TABLE IF EXISTS "safety_quiz_results" CASCADE; +DROP TABLE IF EXISTS "roles" CASCADE; +DROP TABLE IF EXISTS "personality_quiz_results" CASCADE; +DROP TABLE IF EXISTS "permissions" CASCADE; +DROP TABLE IF EXISTS "payments" CASCADE; +DROP TABLE IF EXISTS "organizations" CASCADE; +DROP TABLE IF EXISTS "messages" CASCADE; +DROP TABLE IF EXISTS "message_recipients" CASCADE; +DROP TABLE IF EXISTS "invoices" CASCADE; +DROP TABLE IF EXISTS "guardians" CASCADE; +DROP TABLE IF EXISTS "grades" CASCADE; +DROP TABLE IF EXISTS "frame_entries" CASCADE; +DROP TABLE IF EXISTS "files" CASCADE; +DROP TABLE IF EXISTS "fee_plans" CASCADE; +DROP TABLE IF EXISTS "documents" CASCADE; +DROP TABLE IF EXISTS "content_catalog" CASCADE; +DROP TABLE IF EXISTS "communication_events" CASCADE; +DROP TABLE IF EXISTS "classes" CASCADE; +DROP TABLE IF EXISTS "class_subjects" CASCADE; +DROP TABLE IF EXISTS "class_enrollments" CASCADE; +DROP TABLE IF EXISTS "campuses" CASCADE; +DROP TABLE IF EXISTS "campus_attendance_summaries" CASCADE; +DROP TABLE IF EXISTS "campus_attendance_config" CASCADE; +DROP TABLE IF EXISTS "auth_refresh_tokens" CASCADE; +DROP TABLE IF EXISTS "attendance_sessions" CASCADE; +DROP TABLE IF EXISTS "attendance_records" CASCADE; +DROP TABLE IF EXISTS "assessments" CASCADE; +DROP TABLE IF EXISTS "assessment_results" CASCADE; +DROP TABLE IF EXISTS "academic_years" CASCADE; +DROP TABLE IF EXISTS "users" CASCADE; +DROP TYPE IF EXISTS "public"."enum_assessment_results_grade_letter"; +DROP TYPE IF EXISTS "public"."enum_assessments_assessment_type"; +DROP TYPE IF EXISTS "public"."enum_assessments_status"; +DROP TYPE IF EXISTS "public"."enum_attendance_records_status"; +DROP TYPE IF EXISTS "public"."enum_attendance_sessions_session_type"; +DROP TYPE IF EXISTS "public"."enum_class_enrollments_status"; +DROP TYPE IF EXISTS "public"."enum_class_subjects_status"; +DROP TYPE IF EXISTS "public"."enum_classes_status"; +DROP TYPE IF EXISTS "public"."enum_communication_events_event_type"; +DROP TYPE IF EXISTS "public"."enum_documents_entity_type"; +DROP TYPE IF EXISTS "public"."enum_documents_category"; +DROP TYPE IF EXISTS "public"."enum_fee_plans_billing_cycle"; +DROP TYPE IF EXISTS "public"."enum_guardians_relationship"; +DROP TYPE IF EXISTS "public"."enum_invoices_status"; +DROP TYPE IF EXISTS "public"."enum_message_recipients_recipient_type"; +DROP TYPE IF EXISTS "public"."enum_message_recipients_delivery_status"; +DROP TYPE IF EXISTS "public"."enum_messages_channel"; +DROP TYPE IF EXISTS "public"."enum_messages_audience"; +DROP TYPE IF EXISTS "public"."enum_messages_status"; +DROP TYPE IF EXISTS "public"."enum_payments_method"; +DROP TYPE IF EXISTS "public"."enum_staff_staff_type"; +DROP TYPE IF EXISTS "public"."enum_staff_status"; +DROP TYPE IF EXISTS "public"."enum_staff_attendance_records_status"; +DROP TYPE IF EXISTS "public"."enum_students_gender"; +DROP TYPE IF EXISTS "public"."enum_students_status"; +DROP TYPE IF EXISTS "public"."enum_timetable_periods_day_of_week"; +DROP TYPE IF EXISTS "public"."enum_timetables_status"; +DROP TYPE IF EXISTS "public"."enum_user_progress_progress_type"; +`; diff --git a/backend/src/routes/contactForm.js b/backend/src/db/migrations/.gitkeep similarity index 100% rename from backend/src/routes/contactForm.js rename to backend/src/db/migrations/.gitkeep diff --git a/backend/src/db/migrations/1780900467352.js b/backend/src/db/migrations/1780900467352.js deleted file mode 100644 index c84c85b..0000000 --- a/backend/src/db/migrations/1780900467352.js +++ /dev/null @@ -1,5693 +0,0 @@ -module.exports = { - /** - * @param {QueryInterface} queryInterface - * @param {Sequelize} Sequelize - * @returns {Promise} - */ - async up(queryInterface, Sequelize) { - /** - * @type {Transaction} - */ - const transaction = await queryInterface.sequelize.transaction(); - try { - - - await queryInterface.createTable('users', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('roles', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('permissions', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('organizations', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('campuses', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('academic_years', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('grades', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('subjects', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('students', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('guardians', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('staff', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('classes', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('class_enrollments', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('class_subjects', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('timetables', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('timetable_periods', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('attendance_sessions', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('attendance_records', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('fee_plans', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('invoices', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('payments', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('assessments', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('assessment_results', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('messages', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('message_recipients', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - await queryInterface.createTable('documents', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - references: { - key: 'id', - model: 'users', - }, - }, - createdAt: { type: Sequelize.DataTypes.DATE }, - updatedAt: { type: Sequelize.DataTypes.DATE }, - deletedAt: { type: Sequelize.DataTypes.DATE }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, { transaction }); - - - - - await queryInterface.addColumn( - 'users', - 'firstName', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'lastName', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'phoneNumber', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'email', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'disabled', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'users', - 'password', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'emailVerified', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'emailVerificationToken', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'emailVerificationTokenExpiresAt', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'passwordResetToken', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'passwordResetTokenExpiresAt', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'provider', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'users', - 'app_roleId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'roles', - key: 'id', - }, - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'users', - 'organizationsId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'roles', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'roles', - 'role_customization', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'roles', - 'globalAccess', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'permissions', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'organizations', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'code', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'address', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'phone', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'email', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'campuses', - 'active', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'academic_years', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'academic_years', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'academic_years', - 'start_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'academic_years', - 'end_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'academic_years', - 'current', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'grades', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'grades', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'grades', - 'code', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'grades', - 'sort_order', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'grades', - 'description', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'subjects', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'subjects', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'subjects', - 'code', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'subjects', - 'description', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'student_number', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'first_name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'last_name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'gender', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['male','female','other','prefer_not_to_say'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'date_of_birth', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'enrollment_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['prospect','enrolled','inactive','graduated','transferred'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'email', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'phone', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'students', - 'address', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'guardians', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'studentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'students', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'full_name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'relationship', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['mother','father','guardian','other'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'phone', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'email', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'address', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'guardians', - 'primary_contact', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'userId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'users', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'employee_number', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'job_title', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'staff_type', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['teacher','admin','support'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'hire_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'staff', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['active','on_leave','inactive'], - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'classes', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'academic_yearId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'academic_years', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'gradeId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'grades', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'section', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'homeroom_teacherId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'staff', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'capacity', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'classes', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['active','archived'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'classId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'classes', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'studentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'students', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'enrolled_on', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'ended_on', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_enrollments', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['active','dropped','completed'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_subjects', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_subjects', - 'classId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'classes', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_subjects', - 'subjectId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'subjects', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_subjects', - 'teacherId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'staff', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'class_subjects', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['active','archived'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'academic_yearId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'academic_years', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'effective_from', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'effective_to', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetables', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['draft','active','archived'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'timetableId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'timetables', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'class_subjectId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'class_subjects', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'day_of_week', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'starts_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'ends_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'timetable_periods', - 'room', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'classId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'classes', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'class_subjectId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'class_subjects', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'taken_byId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'staff', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'session_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'session_type', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['homeroom','subject','exam','event'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_sessions', - 'notes', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'attendance_sessionId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'attendance_sessions', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'studentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'students', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['present','absent','late','excused'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'minutes_late', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'attendance_records', - 'remarks', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'academic_yearId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'academic_years', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'gradeId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'grades', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'billing_cycle', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['one_time','monthly','termly','annual'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'total_amount', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'active', - { - type: Sequelize.DataTypes.BOOLEAN, - - defaultValue: false, - allowNull: false, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'fee_plans', - 'notes', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'studentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'students', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'fee_planId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'fee_plans', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'invoice_number', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'issue_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'due_date', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'subtotal', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'discount_amount', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'tax_amount', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'total_amount', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'balance_due', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['draft','issued','partially_paid','paid','overdue','void'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'invoices', - 'notes', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'payments', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'invoiceId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'invoices', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'received_byId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'staff', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'receipt_number', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'paid_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'amount', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'method', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['cash','bank_transfer','card','mobile_money','cheque','other'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'reference_code', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'payments', - 'notes', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'assessments', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'class_subjectId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'class_subjects', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'assessment_type', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['quiz','homework','project','midterm','final','other'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'assigned_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'due_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'max_score', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['draft','published','closed'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessments', - 'instructions', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'assessment_results', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessment_results', - 'assessmentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'assessments', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessment_results', - 'studentId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'students', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessment_results', - 'score', - { - type: Sequelize.DataTypes.DECIMAL, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessment_results', - 'grade_letter', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['A','B','C','D','E','F','P','N'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'assessment_results', - 'remarks', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'sent_byId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'users', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'subject', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'body', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'channel', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['in_app','email','sms'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'audience', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['all_org','campus','class','staff','students','guardians','custom'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'sent_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'messages', - 'status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['draft','scheduled','sent','failed'], - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'message_recipients', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'messageId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'messages', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'recipient_type', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['user','student','guardian'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'recipient_label', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'destination', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'delivery_status', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['pending','sent','delivered','failed','read'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'delivered_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'message_recipients', - 'read_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'organizationId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'organizations', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'campusId', - { - type: Sequelize.DataTypes.UUID, - - - - references: { - model: 'campuses', - key: 'id', - }, - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'entity_type', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['student','staff','class','invoice','organization','campus','other'], - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'entity_reference', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'name', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'category', - { - type: Sequelize.DataTypes.ENUM, - - - values: ['policy','report','id','medical','consent','invoice','receipt','other'], - - - }, - { transaction } - ); - - - - - - - await queryInterface.addColumn( - 'documents', - 'uploaded_at', - { - type: Sequelize.DataTypes.DATE, - - - - }, - { transaction } - ); - - - - - await queryInterface.addColumn( - 'documents', - 'notes', - { - type: Sequelize.DataTypes.TEXT, - - - - }, - { transaction } - ); - - - - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - /** - * @param {QueryInterface} queryInterface - * @param {Sequelize} Sequelize - * @returns {Promise} - */ - async down(queryInterface, Sequelize) { - /** - * @type {Transaction} - */ - const transaction = await queryInterface.sequelize.transaction(); - try { - - - await queryInterface.removeColumn( - 'documents', - 'notes', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'uploaded_at', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'documents', - 'category', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'entity_reference', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'entity_type', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'documents', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'read_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'delivered_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'delivery_status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'destination', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'recipient_label', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'recipient_type', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'messageId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'message_recipients', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'messages', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'sent_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'audience', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'channel', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'body', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'subject', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'sent_byId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'messages', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'remarks', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'grade_letter', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'score', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'studentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'assessmentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessment_results', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'assessments', - 'instructions', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'max_score', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'due_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'assigned_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'assessment_type', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'class_subjectId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'assessments', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'payments', - 'notes', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'reference_code', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'method', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'amount', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'paid_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'receipt_number', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'received_byId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'invoiceId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'payments', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'invoices', - 'notes', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'balance_due', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'total_amount', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'tax_amount', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'discount_amount', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'subtotal', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'due_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'issue_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'invoice_number', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'fee_planId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'studentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'invoices', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'notes', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'active', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'total_amount', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'billing_cycle', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'gradeId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'academic_yearId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'fee_plans', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'remarks', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'minutes_late', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'studentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'attendance_sessionId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_records', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'notes', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'session_type', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'session_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'taken_byId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'class_subjectId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'classId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'attendance_sessions', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'room', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'ends_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'starts_at', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'day_of_week', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'class_subjectId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'timetableId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetable_periods', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'effective_to', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'effective_from', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'academic_yearId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'timetables', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_subjects', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_subjects', - 'teacherId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_subjects', - 'subjectId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_subjects', - 'classId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_subjects', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'ended_on', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'enrolled_on', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'studentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'classId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'class_enrollments', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'capacity', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'homeroom_teacherId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'section', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'gradeId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'academic_yearId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'classes', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'staff', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'hire_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'staff_type', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'job_title', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'employee_number', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'userId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'staff', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'primary_contact', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'address', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'email', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'phone', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'relationship', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'full_name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'studentId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'guardians', - 'organizationId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'students', - 'address', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'phone', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'email', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'status', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'enrollment_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'date_of_birth', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'gender', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'last_name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'first_name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'student_number', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'campusId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'students', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'subjects', - 'description', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'subjects', - 'code', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'subjects', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'subjects', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'grades', - 'description', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'grades', - 'sort_order', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'grades', - 'code', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'grades', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'grades', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'academic_years', - 'current', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'academic_years', - 'end_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'academic_years', - 'start_date', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'academic_years', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'academic_years', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'active', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'email', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'phone', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'address', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'code', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'campuses', - 'organizationId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'organizations', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'permissions', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'roles', - 'globalAccess', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'roles', - 'role_customization', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'roles', - 'name', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'organizationsId', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'users', - 'app_roleId', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'provider', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'passwordResetTokenExpiresAt', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'passwordResetToken', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'emailVerificationTokenExpiresAt', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'emailVerificationToken', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'emailVerified', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'password', - { transaction } - ); - - - - - - await queryInterface.removeColumn( - 'users', - 'disabled', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'email', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'phoneNumber', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'lastName', - { transaction } - ); - - - - await queryInterface.removeColumn( - 'users', - 'firstName', - { transaction } - ); - - - - await queryInterface.dropTable('documents', { transaction }); - - - - await queryInterface.dropTable('message_recipients', { transaction }); - - - - await queryInterface.dropTable('messages', { transaction }); - - - - await queryInterface.dropTable('assessment_results', { transaction }); - - - - await queryInterface.dropTable('assessments', { transaction }); - - - - await queryInterface.dropTable('payments', { transaction }); - - - - await queryInterface.dropTable('invoices', { transaction }); - - - - await queryInterface.dropTable('fee_plans', { transaction }); - - - - await queryInterface.dropTable('attendance_records', { transaction }); - - - - await queryInterface.dropTable('attendance_sessions', { transaction }); - - - - await queryInterface.dropTable('timetable_periods', { transaction }); - - - - await queryInterface.dropTable('timetables', { transaction }); - - - - await queryInterface.dropTable('class_subjects', { transaction }); - - - - await queryInterface.dropTable('class_enrollments', { transaction }); - - - - await queryInterface.dropTable('classes', { transaction }); - - - - await queryInterface.dropTable('staff', { transaction }); - - - - await queryInterface.dropTable('guardians', { transaction }); - - - - await queryInterface.dropTable('students', { transaction }); - - - - await queryInterface.dropTable('subjects', { transaction }); - - - - await queryInterface.dropTable('grades', { transaction }); - - - - await queryInterface.dropTable('academic_years', { transaction }); - - - - await queryInterface.dropTable('campuses', { transaction }); - - - - await queryInterface.dropTable('organizations', { transaction }); - - - - await queryInterface.dropTable('permissions', { transaction }); - - - - await queryInterface.dropTable('roles', { transaction }); - - - - await queryInterface.dropTable('users', { transaction }); - - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - } -}; diff --git a/backend/src/db/migrations/20260316000000-create-files.js b/backend/src/db/migrations/20260316000000-create-files.js deleted file mode 100644 index 69e9594..0000000 --- a/backend/src/db/migrations/20260316000000-create-files.js +++ /dev/null @@ -1,124 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.files') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - const tableName = rows[0].regclass_name; - - if (tableName) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'files', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - belongsTo: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - }, - belongsToId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - }, - belongsToColumn: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - }, - name: { - type: Sequelize.DataTypes.STRING(2083), - allowNull: false, - }, - sizeInBytes: { - type: Sequelize.DataTypes.INTEGER, - allowNull: true, - }, - privateUrl: { - type: Sequelize.DataTypes.STRING(2083), - allowNull: true, - }, - publicUrl: { - type: Sequelize.DataTypes.STRING(2083), - allowNull: false, - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - }, - { transaction }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.files') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - const tableName = rows[0].regclass_name; - - if (!tableName) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('files', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260316001000-create-users-custom-permissions-join-table.js b/backend/src/db/migrations/20260316001000-create-users-custom-permissions-join-table.js deleted file mode 100644 index c0e5634..0000000 --- a/backend/src/db/migrations/20260316001000-create-users-custom-permissions-join-table.js +++ /dev/null @@ -1,77 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.\"usersCustom_permissionsPermissions\"') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - const tableName = rows[0].regclass_name; - - if (tableName) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'usersCustom_permissionsPermissions', - { - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - users_custom_permissionsId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - permissionId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - primaryKey: true, - }, - }, - { transaction }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.\"usersCustom_permissionsPermissions\"') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - const tableName = rows[0].regclass_name; - - if (!tableName) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('usersCustom_permissionsPermissions', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608000000-create-frame-entries.js b/backend/src/db/migrations/20260608000000-create-frame-entries.js deleted file mode 100644 index bc2dbbc..0000000 --- a/backend/src/db/migrations/20260608000000-create-frame-entries.js +++ /dev/null @@ -1,151 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.frame_entries') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'frame_entries', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - week_of: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - posted_date: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - formal: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - recognition: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - application: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - management: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - emotional: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - author: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.frame_entries') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('frame_entries', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608001000-create-user-progress.js b/backend/src/db/migrations/20260608001000-create-user-progress.js deleted file mode 100644 index c2bb5a5..0000000 --- a/backend/src/db/migrations/20260608001000-create-user-progress.js +++ /dev/null @@ -1,158 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.user_progress') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'user_progress', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - progress_type: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - item_id: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - value: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - score: { - type: Sequelize.DataTypes.INTEGER, - allowNull: true, - }, - metadata: { - type: Sequelize.DataTypes.JSONB, - allowNull: true, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - userId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'user_progress', - ['organizationId', 'userId', 'progress_type', 'item_id'], - { - name: 'user_progress_owner_item_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.user_progress') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('user_progress', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js b/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js deleted file mode 100644 index 510a598..0000000 --- a/backend/src/db/migrations/20260608002000-create-safety-quiz-results.js +++ /dev/null @@ -1,174 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.safety_quiz_results') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'safety_quiz_results', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - quiz_id: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - quiz_title: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - week_of: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - score: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - }, - total_questions: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - }, - answers: { - type: Sequelize.DataTypes.JSONB, - allowNull: false, - }, - user_name: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - user_role: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - completed_at: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - userId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'safety_quiz_results', - ['organizationId', 'week_of', 'userId'], - { - name: 'safety_quiz_results_org_week_user_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.safety_quiz_results') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('safety_quiz_results', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js b/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js deleted file mode 100644 index 0f1fd5a..0000000 --- a/backend/src/db/migrations/20260608003000-create-walkthrough-checkins.js +++ /dev/null @@ -1,127 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.walkthrough_checkins') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'walkthrough_checkins', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - teacher_name: { type: Sequelize.DataTypes.TEXT, allowNull: false }, - classroom: { type: Sequelize.DataTypes.TEXT, allowNull: false }, - director_name: { type: Sequelize.DataTypes.TEXT, allowNull: false }, - check_in_date: { type: Sequelize.DataTypes.DATEONLY, allowNull: false }, - check_in_time: { type: Sequelize.DataTypes.TIME, allowNull: false }, - attitude_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - attitude_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - classroom_management_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - classroom_management_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - cleanliness_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - cleanliness_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - vibes_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - vibes_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - team_dynamics_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - team_dynamics_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - emergency_exit_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - emergency_exit_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - lesson_plan_rating: { type: Sequelize.DataTypes.INTEGER, allowNull: false }, - lesson_plan_comment: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - overall_notes: { type: Sequelize.DataTypes.TEXT, allowNull: true }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { key: 'id', model: 'organizations' }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { key: 'id', model: 'campuses' }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { key: 'id', model: 'users' }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { key: 'id', model: 'users' }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, - updatedAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, - deletedAt: { type: Sequelize.DataTypes.DATE, allowNull: true }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'walkthrough_checkins', - ['organizationId', 'check_in_date'], - { - name: 'walkthrough_checkins_org_date_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.walkthrough_checkins') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('walkthrough_checkins', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608004000-create-communication-events.js b/backend/src/db/migrations/20260608004000-create-communication-events.js deleted file mode 100644 index 227887c..0000000 --- a/backend/src/db/migrations/20260608004000-create-communication-events.js +++ /dev/null @@ -1,145 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.communication_events') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'communication_events', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - title: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - event_date: { - type: Sequelize.DataTypes.DATEONLY, - allowNull: false, - }, - event_type: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - roles: { - type: Sequelize.DataTypes.JSONB, - allowNull: false, - defaultValue: [], - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'communication_events', - ['organizationId', 'campusId', 'event_date'], - { - name: 'communication_events_org_campus_date_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.communication_events') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('communication_events', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js b/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js deleted file mode 100644 index 0299bdf..0000000 --- a/backend/src/db/migrations/20260608005000-create-personality-quiz-results.js +++ /dev/null @@ -1,160 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.personality_quiz_results') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'personality_quiz_results', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - personality_type: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - quiz_answers: { - type: Sequelize.DataTypes.JSONB, - allowNull: false, - }, - completed_at: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - userId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'personality_quiz_results', - ['organizationId', 'userId'], - { - unique: true, - name: 'personality_quiz_results_org_user_unique', - transaction, - }, - ); - - await queryInterface.addIndex( - 'personality_quiz_results', - ['organizationId', 'campusId', 'personality_type'], - { - name: 'personality_quiz_results_distribution_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.personality_quiz_results') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('personality_quiz_results', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js b/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js deleted file mode 100644 index d731364..0000000 --- a/backend/src/db/migrations/20260608006000-create-campus-attendance-config.js +++ /dev/null @@ -1,141 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.campus_attendance_config') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'campus_attendance_config', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - campus_key: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - attendance_link: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - updated_by_label: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'campus_attendance_config', - ['organizationId', 'campus_key'], - { - unique: true, - name: 'campus_attendance_config_org_campus_key_unique', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.campus_attendance_config') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('campus_attendance_config', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js b/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js deleted file mode 100644 index 148b754..0000000 --- a/backend/src/db/migrations/20260608007000-create-campus-attendance-summaries.js +++ /dev/null @@ -1,166 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.campus_attendance_summaries') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'campus_attendance_summaries', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - campus_key: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - attendance_date: { - type: Sequelize.DataTypes.DATEONLY, - allowNull: false, - }, - total_enrolled: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - }, - total_present: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - }, - total_absent: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - }, - total_tardy: { - type: Sequelize.DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - }, - attendance_percentage: { - type: Sequelize.DataTypes.DECIMAL(5, 2), - allowNull: false, - }, - recorded_by_label: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - notes: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'campus_attendance_summaries', - ['organizationId', 'campus_key', 'attendance_date'], - { - unique: true, - name: 'campus_attendance_summaries_org_campus_date_unique', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.campus_attendance_summaries') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('campus_attendance_summaries', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js b/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js deleted file mode 100644 index 38ba791..0000000 --- a/backend/src/db/migrations/20260608008000-create-staff-attendance-records.js +++ /dev/null @@ -1,170 +0,0 @@ -const { STAFF_ATTENDANCE_STATUSES } = require('../../constants/staff-attendance'); - -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.staff_attendance_records') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'staff_attendance_records', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - attendance_date: { - type: Sequelize.DataTypes.DATEONLY, - allowNull: false, - }, - status: { - type: Sequelize.DataTypes.ENUM(...Object.values(STAFF_ATTENDANCE_STATUSES)), - allowNull: false, - }, - note: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - user_name: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - user_role: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - campusId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'campuses', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - userId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - createdById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - updatedById: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'staff_attendance_records', - ['organizationId', 'campusId', 'attendance_date'], - { - name: 'staff_attendance_records_org_campus_date_idx', - transaction, - }, - ); - - await queryInterface.addIndex( - 'staff_attendance_records', - ['organizationId', 'userId', 'attendance_date'], - { - name: 'staff_attendance_records_org_user_date_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.staff_attendance_records') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('staff_attendance_records', { transaction }); - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_staff_attendance_records_status";', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js b/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js deleted file mode 100644 index 9c23ac5..0000000 --- a/backend/src/db/migrations/20260608009000-create-auth-refresh-tokens.js +++ /dev/null @@ -1,148 +0,0 @@ -module.exports = { - async up(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.auth_refresh_tokens') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.createTable( - 'auth_refresh_tokens', - { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - userId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - references: { - key: 'id', - model: 'users', - }, - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - }, - organizationId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - references: { - key: 'id', - model: 'organizations', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - }, - tokenHash: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - }, - familyId: { - type: Sequelize.DataTypes.UUID, - allowNull: false, - }, - previousTokenId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - }, - userAgent: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - ipAddress: { - type: Sequelize.DataTypes.TEXT, - allowNull: true, - }, - expiresAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - revokedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - replacedByTokenId: { - type: Sequelize.DataTypes.UUID, - allowNull: true, - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - }, - { transaction }, - ); - - await queryInterface.addIndex( - 'auth_refresh_tokens', - ['tokenHash'], - { - name: 'auth_refresh_tokens_token_hash_idx', - transaction, - unique: true, - }, - ); - await queryInterface.addIndex( - 'auth_refresh_tokens', - ['familyId'], - { - name: 'auth_refresh_tokens_family_id_idx', - transaction, - }, - ); - await queryInterface.addIndex( - 'auth_refresh_tokens', - ['userId'], - { - name: 'auth_refresh_tokens_user_id_idx', - transaction, - }, - ); - - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, - - async down(queryInterface, Sequelize) { - const transaction = await queryInterface.sequelize.transaction(); - - try { - const rows = await queryInterface.sequelize.query( - "SELECT to_regclass('public.auth_refresh_tokens') AS regclass_name;", - { - transaction, - type: Sequelize.QueryTypes.SELECT, - }, - ); - - if (!rows[0].regclass_name) { - await transaction.commit(); - return; - } - - await queryInterface.dropTable('auth_refresh_tokens', { transaction }); - await transaction.commit(); - } catch (err) { - await transaction.rollback(); - throw err; - } - }, -}; diff --git a/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js b/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js deleted file mode 100644 index 3e16f50..0000000 --- a/backend/src/db/migrations/20260608101000-add-campus-branding-fields.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -const CAMPUS_BRANDING_COLUMNS = Object.freeze({ - mascot: { - type: 'TEXT', - allowNull: true, - }, - color: { - type: 'TEXT', - allowNull: true, - }, - bgGradient: { - type: 'TEXT', - allowNull: true, - }, - borderColor: { - type: 'TEXT', - allowNull: true, - }, - textColor: { - type: 'TEXT', - allowNull: true, - }, - bgLight: { - type: 'TEXT', - allowNull: true, - }, - description: { - type: 'TEXT', - allowNull: true, - }, - isOnline: { - type: 'BOOLEAN', - allowNull: false, - defaultValue: false, - }, -}); - -function toColumnDefinition(Sequelize, definition) { - return { - ...definition, - type: Sequelize.DataTypes[definition.type], - }; -} - -module.exports = { - up: async (queryInterface, Sequelize) => { - await Promise.all(Object.entries(CAMPUS_BRANDING_COLUMNS).map(([columnName, definition]) => ( - queryInterface.addColumn('campuses', columnName, toColumnDefinition(Sequelize, definition)) - ))); - }, - - down: async (queryInterface) => { - await Promise.all(Object.keys(CAMPUS_BRANDING_COLUMNS).map((columnName) => ( - queryInterface.removeColumn('campuses', columnName) - ))); - }, -}; diff --git a/backend/src/db/migrations/20260608102000-create-content-catalog.js b/backend/src/db/migrations/20260608102000-create-content-catalog.js deleted file mode 100644 index bdbda54..0000000 --- a/backend/src/db/migrations/20260608102000-create-content-catalog.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.createTable('content_catalog', { - id: { - type: Sequelize.DataTypes.UUID, - defaultValue: Sequelize.DataTypes.UUIDV4, - primaryKey: true, - }, - content_type: { - type: Sequelize.DataTypes.TEXT, - allowNull: false, - unique: true, - }, - payload: { - type: Sequelize.DataTypes.JSONB, - allowNull: false, - }, - active: { - type: Sequelize.DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, - createdAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: false, - }, - deletedAt: { - type: Sequelize.DataTypes.DATE, - allowNull: true, - }, - importHash: { - type: Sequelize.DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }); - - await queryInterface.addIndex('content_catalog', ['content_type'], { - name: 'content_catalog_content_type_idx', - unique: true, - }); - }, - - async down(queryInterface) { - await queryInterface.dropTable('content_catalog'); - }, -}; diff --git a/backend/src/db/migrations/20260610000000-initial-schema.ts b/backend/src/db/migrations/20260610000000-initial-schema.ts new file mode 100644 index 0000000..5111b7f --- /dev/null +++ b/backend/src/db/migrations/20260610000000-initial-schema.ts @@ -0,0 +1,17 @@ +import type { QueryInterface } from 'sequelize'; +import { INITIAL_SCHEMA_UP, INITIAL_SCHEMA_DOWN } from '@/db/initial-schema'; + +/** + * Initial schema. The DDL is a snapshot generated from the Sequelize models + * (see `@/db/initial-schema`). It mirrors exactly what `sequelize.sync()` would + * produce, so the models stay the source of truth while the schema is now + * created and tracked through migrations. + */ +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.sequelize.query(INITIAL_SCHEMA_UP); + }, + down: async (queryInterface: QueryInterface) => { + await queryInterface.sequelize.query(INITIAL_SCHEMA_DOWN); + }, +}; diff --git a/backend/src/db/models/academic_years.js b/backend/src/db/models/academic_years.js deleted file mode 100644 index 609fd72..0000000 --- a/backend/src/db/models/academic_years.js +++ /dev/null @@ -1,147 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const academic_years = sequelize.define( - 'academic_years', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -start_date: { - type: DataTypes.DATE, - - - - }, - -end_date: { - type: DataTypes.DATE, - - - - }, - -current: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - academic_years.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - db.academic_years.hasMany(db.classes, { - as: 'classes_academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - - - - db.academic_years.hasMany(db.timetables, { - as: 'timetables_academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - - - - - db.academic_years.hasMany(db.fee_plans, { - as: 'fee_plans_academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - - - - - - - - - -//end loop - - - - db.academic_years.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - - - db.academic_years.belongsTo(db.users, { - as: 'createdBy', - }); - - db.academic_years.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return academic_years; -}; - - diff --git a/backend/src/db/models/academic_years.ts b/backend/src/db/models/academic_years.ts new file mode 100644 index 0000000..46aaaae --- /dev/null +++ b/backend/src/db/models/academic_years.ts @@ -0,0 +1,197 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Classes } from './classes'; +import type { FeePlans } from './fee_plans'; +import type { Organizations } from './organizations'; +import type { Timetables } from './timetables'; +import type { Users } from './users'; + +export class AcademicYears extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare start_date: Date | null; + declare end_date: Date | null; + declare current: CreationOptional; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getClasses_academic_year: HasManyGetAssociationsMixin; + declare setClasses_academic_year: HasManySetAssociationsMixin; + declare getTimetables_academic_year: HasManyGetAssociationsMixin; + declare setTimetables_academic_year: HasManySetAssociationsMixin; + declare getFee_plans_academic_year: HasManyGetAssociationsMixin; + declare setFee_plans_academic_year: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + db.academic_years.hasMany(db.classes, { + as: 'classes_academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + + + + db.academic_years.hasMany(db.timetables, { + as: 'timetables_academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + + + + + db.academic_years.hasMany(db.fee_plans, { + as: 'fee_plans_academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + + + + + + + + + +//end loop + + + + db.academic_years.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + + + db.academic_years.belongsTo(db.users, { + as: 'createdBy', + }); + + db.academic_years.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof AcademicYears { + AcademicYears.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +start_date: { + type: DataTypes.DATE, + + + + }, + +end_date: { + type: DataTypes.DATE, + + + + }, + +current: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'academic_years', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return AcademicYears; +} diff --git a/backend/src/db/models/assessment_results.js b/backend/src/db/models/assessment_results.js deleted file mode 100644 index 918cba8..0000000 --- a/backend/src/db/models/assessment_results.js +++ /dev/null @@ -1,156 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const assessment_results = sequelize.define( - 'assessment_results', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -score: { - type: DataTypes.DECIMAL, - - - - }, - -grade_letter: { - type: DataTypes.ENUM, - - - - values: [ - -"A", - - -"B", - - -"C", - - -"D", - - -"E", - - -"F", - - -"P", - - -"N" - - ], - - }, - -remarks: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - assessment_results.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.assessment_results.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.assessment_results.belongsTo(db.assessments, { - as: 'assessment', - foreignKey: { - name: 'assessmentId', - }, - constraints: false, - }); - - db.assessment_results.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.assessment_results.belongsTo(db.users, { - as: 'createdBy', - }); - - db.assessment_results.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return assessment_results; -}; - - diff --git a/backend/src/db/models/assessment_results.ts b/backend/src/db/models/assessment_results.ts new file mode 100644 index 0000000..cdb8085 --- /dev/null +++ b/backend/src/db/models/assessment_results.ts @@ -0,0 +1,204 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Assessments } from './assessments'; +import type { Organizations } from './organizations'; +import type { Students } from './students'; +import type { Users } from './users'; + +export class AssessmentResults extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare score: string | null; + declare grade_letter: string | null; + declare remarks: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare assessmentId: CreationOptional; + declare studentId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getAssessment: BelongsToGetAssociationMixin; + declare setAssessment: BelongsToSetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare setStudent: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.assessment_results.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.assessment_results.belongsTo(db.assessments, { + as: 'assessment', + foreignKey: { + name: 'assessmentId', + }, + constraints: false, + }); + + db.assessment_results.belongsTo(db.students, { + as: 'student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.assessment_results.belongsTo(db.users, { + as: 'createdBy', + }); + + db.assessment_results.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof AssessmentResults { + AssessmentResults.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +score: { + type: DataTypes.DECIMAL, + + + + }, + +grade_letter: { + type: DataTypes.ENUM, + + + + values: [ + +"A", + + +"B", + + +"C", + + +"D", + + +"E", + + +"F", + + +"P", + + +"N" + + ], + + }, + +remarks: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + assessmentId: { type: DataTypes.UUID, allowNull: true }, + studentId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'assessment_results', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return AssessmentResults; +} diff --git a/backend/src/db/models/assessments.js b/backend/src/db/models/assessments.js deleted file mode 100644 index 5b2e05c..0000000 --- a/backend/src/db/models/assessments.js +++ /dev/null @@ -1,200 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const assessments = sequelize.define( - 'assessments', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -assessment_type: { - type: DataTypes.ENUM, - - - - values: [ - -"quiz", - - -"homework", - - -"project", - - -"midterm", - - -"final", - - -"other" - - ], - - }, - -assigned_at: { - type: DataTypes.DATE, - - - - }, - -due_at: { - type: DataTypes.DATE, - - - - }, - -max_score: { - type: DataTypes.DECIMAL, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"draft", - - -"published", - - -"closed" - - ], - - }, - -instructions: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - assessments.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - db.assessments.hasMany(db.assessment_results, { - as: 'assessment_results_assessment', - foreignKey: { - name: 'assessmentId', - }, - constraints: false, - }); - - - - - - -//end loop - - - - db.assessments.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.assessments.belongsTo(db.class_subjects, { - as: 'class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - - - db.assessments.hasMany(db.file, { - as: 'attachments', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.assessments.getTableName(), - belongsToColumn: 'attachments', - }, - }); - - - db.assessments.belongsTo(db.users, { - as: 'createdBy', - }); - - db.assessments.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return assessments; -}; - - diff --git a/backend/src/db/models/assessments.ts b/backend/src/db/models/assessments.ts new file mode 100644 index 0000000..8a97db2 --- /dev/null +++ b/backend/src/db/models/assessments.ts @@ -0,0 +1,255 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AssessmentResults } from './assessment_results'; +import type { ClassSubjects } from './class_subjects'; +import type { File } from './file'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Assessments extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare assessment_type: string | null; + declare assigned_at: Date | null; + declare due_at: Date | null; + declare max_score: string | null; + declare status: string | null; + declare instructions: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare class_subjectId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getAssessment_results_assessment: HasManyGetAssociationsMixin; + declare setAssessment_results_assessment: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getClass_subject: BelongsToGetAssociationMixin; + declare setClass_subject: BelongsToSetAssociationMixin; + declare getAttachments: HasManyGetAssociationsMixin; + declare setAttachments: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + db.assessments.hasMany(db.assessment_results, { + as: 'assessment_results_assessment', + foreignKey: { + name: 'assessmentId', + }, + constraints: false, + }); + + + + + + +//end loop + + + + db.assessments.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.assessments.belongsTo(db.class_subjects, { + as: 'class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + + + db.assessments.hasMany(db.file, { + as: 'attachments', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.assessments.getTableName(), + belongsToColumn: 'attachments', + }, + }); + + + db.assessments.belongsTo(db.users, { + as: 'createdBy', + }); + + db.assessments.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Assessments { + Assessments.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +assessment_type: { + type: DataTypes.ENUM, + + + + values: [ + +"quiz", + + +"homework", + + +"project", + + +"midterm", + + +"final", + + +"other" + + ], + + }, + +assigned_at: { + type: DataTypes.DATE, + + + + }, + +due_at: { + type: DataTypes.DATE, + + + + }, + +max_score: { + type: DataTypes.DECIMAL, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"draft", + + +"published", + + +"closed" + + ], + + }, + +instructions: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + class_subjectId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'assessments', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Assessments; +} diff --git a/backend/src/db/models/attendance_records.js b/backend/src/db/models/attendance_records.js deleted file mode 100644 index e931c7c..0000000 --- a/backend/src/db/models/attendance_records.js +++ /dev/null @@ -1,144 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const attendance_records = sequelize.define( - 'attendance_records', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"present", - - -"absent", - - -"late", - - -"excused" - - ], - - }, - -minutes_late: { - type: DataTypes.INTEGER, - - - - }, - -remarks: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - attendance_records.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.attendance_records.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.attendance_records.belongsTo(db.attendance_sessions, { - as: 'attendance_session', - foreignKey: { - name: 'attendance_sessionId', - }, - constraints: false, - }); - - db.attendance_records.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.attendance_records.belongsTo(db.users, { - as: 'createdBy', - }); - - db.attendance_records.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return attendance_records; -}; - - diff --git a/backend/src/db/models/attendance_records.ts b/backend/src/db/models/attendance_records.ts new file mode 100644 index 0000000..e72cc44 --- /dev/null +++ b/backend/src/db/models/attendance_records.ts @@ -0,0 +1,192 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Organizations } from './organizations'; +import type { Students } from './students'; +import type { Users } from './users'; + +export class AttendanceRecords extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare status: string | null; + declare minutes_late: number | null; + declare remarks: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare attendance_sessionId: CreationOptional; + declare studentId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getAttendance_session: BelongsToGetAssociationMixin; + declare setAttendance_session: BelongsToSetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare setStudent: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.attendance_records.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.attendance_records.belongsTo(db.attendance_sessions, { + as: 'attendance_session', + foreignKey: { + name: 'attendance_sessionId', + }, + constraints: false, + }); + + db.attendance_records.belongsTo(db.students, { + as: 'student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.attendance_records.belongsTo(db.users, { + as: 'createdBy', + }); + + db.attendance_records.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof AttendanceRecords { + AttendanceRecords.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"present", + + +"absent", + + +"late", + + +"excused" + + ], + + }, + +minutes_late: { + type: DataTypes.INTEGER, + + + + }, + +remarks: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + attendance_sessionId: { type: DataTypes.UUID, allowNull: true }, + studentId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'attendance_records', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return AttendanceRecords; +} diff --git a/backend/src/db/models/attendance_sessions.js b/backend/src/db/models/attendance_sessions.js deleted file mode 100644 index 43a5b22..0000000 --- a/backend/src/db/models/attendance_sessions.js +++ /dev/null @@ -1,168 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const attendance_sessions = sequelize.define( - 'attendance_sessions', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -session_date: { - type: DataTypes.DATE, - - - - }, - -session_type: { - type: DataTypes.ENUM, - - - - values: [ - -"homeroom", - - -"subject", - - -"exam", - - -"event" - - ], - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - attendance_sessions.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - db.attendance_sessions.hasMany(db.attendance_records, { - as: 'attendance_records_attendance_session', - foreignKey: { - name: 'attendance_sessionId', - }, - constraints: false, - }); - - - - - - - - - - - -//end loop - - - - db.attendance_sessions.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.attendance_sessions.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.attendance_sessions.belongsTo(db.classes, { - as: 'class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - db.attendance_sessions.belongsTo(db.class_subjects, { - as: 'class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - db.attendance_sessions.belongsTo(db.staff, { - as: 'taken_by', - foreignKey: { - name: 'taken_byId', - }, - constraints: false, - }); - - - - - db.attendance_sessions.belongsTo(db.users, { - as: 'createdBy', - }); - - db.attendance_sessions.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return attendance_sessions; -}; - - diff --git a/backend/src/db/models/attendance_sessions.ts b/backend/src/db/models/attendance_sessions.ts new file mode 100644 index 0000000..2b5c88e --- /dev/null +++ b/backend/src/db/models/attendance_sessions.ts @@ -0,0 +1,231 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AttendanceRecords } from './attendance_records'; +import type { Campuses } from './campuses'; +import type { ClassSubjects } from './class_subjects'; +import type { Classes } from './classes'; +import type { Organizations } from './organizations'; +import type { Staff } from './staff'; +import type { Users } from './users'; + +export class AttendanceSessions extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare session_date: Date | null; + declare session_type: string | null; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare classId: CreationOptional; + declare class_subjectId: CreationOptional; + declare taken_byId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getAttendance_records_attendance_session: HasManyGetAssociationsMixin; + declare setAttendance_records_attendance_session: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getClass: BelongsToGetAssociationMixin; + declare setClass: BelongsToSetAssociationMixin; + declare getClass_subject: BelongsToGetAssociationMixin; + declare setClass_subject: BelongsToSetAssociationMixin; + declare getTaken_by: BelongsToGetAssociationMixin; + declare setTaken_by: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + db.attendance_sessions.hasMany(db.attendance_records, { + as: 'attendance_records_attendance_session', + foreignKey: { + name: 'attendance_sessionId', + }, + constraints: false, + }); + + + + + + + + + + + +//end loop + + + + db.attendance_sessions.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.attendance_sessions.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.attendance_sessions.belongsTo(db.classes, { + as: 'class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + db.attendance_sessions.belongsTo(db.class_subjects, { + as: 'class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + db.attendance_sessions.belongsTo(db.staff, { + as: 'taken_by', + foreignKey: { + name: 'taken_byId', + }, + constraints: false, + }); + + + + + db.attendance_sessions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.attendance_sessions.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof AttendanceSessions { + AttendanceSessions.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +session_date: { + type: DataTypes.DATE, + + + + }, + +session_type: { + type: DataTypes.ENUM, + + + + values: [ + +"homeroom", + + +"subject", + + +"exam", + + +"event" + + ], + + }, + +notes: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, + class_subjectId: { type: DataTypes.UUID, allowNull: true }, + taken_byId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'attendance_sessions', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return AttendanceSessions; +} diff --git a/backend/src/db/models/auth_refresh_tokens.js b/backend/src/db/models/auth_refresh_tokens.js deleted file mode 100644 index 2002d48..0000000 --- a/backend/src/db/models/auth_refresh_tokens.js +++ /dev/null @@ -1,77 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const auth_refresh_tokens = sequelize.define( - 'auth_refresh_tokens', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - userId: { - type: DataTypes.UUID, - allowNull: false, - }, - organizationId: { - type: DataTypes.UUID, - allowNull: true, - }, - tokenHash: { - type: DataTypes.TEXT, - allowNull: false, - unique: true, - }, - familyId: { - type: DataTypes.UUID, - allowNull: false, - }, - previousTokenId: { - type: DataTypes.UUID, - allowNull: true, - }, - userAgent: { - type: DataTypes.TEXT, - allowNull: true, - }, - ipAddress: { - type: DataTypes.TEXT, - allowNull: true, - }, - expiresAt: { - type: DataTypes.DATE, - allowNull: false, - }, - revokedAt: { - type: DataTypes.DATE, - allowNull: true, - }, - replacedByTokenId: { - type: DataTypes.UUID, - allowNull: true, - }, - }, - { - timestamps: true, - freezeTableName: true, - }, - ); - - auth_refresh_tokens.associate = (db) => { - db.auth_refresh_tokens.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - db.auth_refresh_tokens.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - }; - - return auth_refresh_tokens; -}; diff --git a/backend/src/db/models/auth_refresh_tokens.ts b/backend/src/db/models/auth_refresh_tokens.ts new file mode 100644 index 0000000..3a701b5 --- /dev/null +++ b/backend/src/db/models/auth_refresh_tokens.ts @@ -0,0 +1,121 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class AuthRefreshTokens extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare userId: string; + declare organizationId: string | null; + declare tokenHash: string; + declare familyId: string; + declare previousTokenId: string | null; + declare userAgent: string | null; + declare ipAddress: string | null; + declare expiresAt: Date; + declare revokedAt: Date | null; + declare replacedByTokenId: string | null; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + + + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.auth_refresh_tokens.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.auth_refresh_tokens.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + } +} + +export default function (sequelize: Sequelize): typeof AuthRefreshTokens { + AuthRefreshTokens.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + organizationId: { + type: DataTypes.UUID, + allowNull: true, + }, + tokenHash: { + type: DataTypes.TEXT, + allowNull: false, + unique: true, + }, + familyId: { + type: DataTypes.UUID, + allowNull: false, + }, + previousTokenId: { + type: DataTypes.UUID, + allowNull: true, + }, + userAgent: { + type: DataTypes.TEXT, + allowNull: true, + }, + ipAddress: { + type: DataTypes.TEXT, + allowNull: true, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + revokedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + replacedByTokenId: { + type: DataTypes.UUID, + allowNull: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + }, + { + sequelize, + modelName: 'auth_refresh_tokens', + timestamps: true, + freezeTableName: true, + }, + ); + + return AuthRefreshTokens; +} diff --git a/backend/src/db/models/campus_attendance_config.js b/backend/src/db/models/campus_attendance_config.js deleted file mode 100644 index be233cd..0000000 --- a/backend/src/db/models/campus_attendance_config.js +++ /dev/null @@ -1,62 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const campus_attendance_config = sequelize.define( - 'campus_attendance_config', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - campus_key: { - type: DataTypes.TEXT, - allowNull: false, - }, - attendance_link: { - type: DataTypes.TEXT, - allowNull: true, - }, - updated_by_label: { - type: DataTypes.TEXT, - allowNull: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - campus_attendance_config.associate = (db) => { - db.campus_attendance_config.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.campus_attendance_config.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.campus_attendance_config.belongsTo(db.users, { - as: 'createdBy', - }); - - db.campus_attendance_config.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return campus_attendance_config; -}; diff --git a/backend/src/db/models/campus_attendance_config.ts b/backend/src/db/models/campus_attendance_config.ts new file mode 100644 index 0000000..bab0c57 --- /dev/null +++ b/backend/src/db/models/campus_attendance_config.ts @@ -0,0 +1,115 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class CampusAttendanceConfig extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare campus_key: string; + declare attendance_link: string | null; + declare updated_by_label: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.campus_attendance_config.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.campus_attendance_config.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.campus_attendance_config.belongsTo(db.users, { + as: 'createdBy', + }); + + db.campus_attendance_config.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof CampusAttendanceConfig { + CampusAttendanceConfig.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: DataTypes.TEXT, + allowNull: false, + }, + attendance_link: { + type: DataTypes.TEXT, + allowNull: true, + }, + updated_by_label: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'campus_attendance_config', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return CampusAttendanceConfig; +} diff --git a/backend/src/db/models/campus_attendance_summaries.js b/backend/src/db/models/campus_attendance_summaries.js deleted file mode 100644 index 810281b..0000000 --- a/backend/src/db/models/campus_attendance_summaries.js +++ /dev/null @@ -1,87 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const campus_attendance_summaries = sequelize.define( - 'campus_attendance_summaries', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - campus_key: { - type: DataTypes.TEXT, - allowNull: false, - }, - attendance_date: { - type: DataTypes.DATEONLY, - allowNull: false, - }, - total_enrolled: { - type: DataTypes.INTEGER, - allowNull: false, - }, - total_present: { - type: DataTypes.INTEGER, - allowNull: false, - }, - total_absent: { - type: DataTypes.INTEGER, - allowNull: false, - }, - total_tardy: { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - }, - attendance_percentage: { - type: DataTypes.DECIMAL(5, 2), - allowNull: false, - }, - recorded_by_label: { - type: DataTypes.TEXT, - allowNull: true, - }, - notes: { - type: DataTypes.TEXT, - allowNull: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - campus_attendance_summaries.associate = (db) => { - db.campus_attendance_summaries.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.campus_attendance_summaries.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.campus_attendance_summaries.belongsTo(db.users, { - as: 'createdBy', - }); - - db.campus_attendance_summaries.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return campus_attendance_summaries; -}; diff --git a/backend/src/db/models/campus_attendance_summaries.ts b/backend/src/db/models/campus_attendance_summaries.ts new file mode 100644 index 0000000..49f3970 --- /dev/null +++ b/backend/src/db/models/campus_attendance_summaries.ts @@ -0,0 +1,146 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class CampusAttendanceSummaries extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare campus_key: string; + declare attendance_date: string; + declare total_enrolled: number; + declare total_present: number; + declare total_absent: number; + declare total_tardy: CreationOptional; + declare attendance_percentage: string; + declare recorded_by_label: string | null; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.campus_attendance_summaries.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.campus_attendance_summaries.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.campus_attendance_summaries.belongsTo(db.users, { + as: 'createdBy', + }); + + db.campus_attendance_summaries.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof CampusAttendanceSummaries { + CampusAttendanceSummaries.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + campus_key: { + type: DataTypes.TEXT, + allowNull: false, + }, + attendance_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + total_enrolled: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_present: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_absent: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_tardy: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + attendance_percentage: { + type: DataTypes.DECIMAL(5, 2), + allowNull: false, + }, + recorded_by_label: { + type: DataTypes.TEXT, + allowNull: true, + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'campus_attendance_summaries', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return CampusAttendanceSummaries; +} diff --git a/backend/src/db/models/campuses.js b/backend/src/db/models/campuses.js deleted file mode 100644 index fa3ea58..0000000 --- a/backend/src/db/models/campuses.js +++ /dev/null @@ -1,258 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const campuses = sequelize.define( - 'campuses', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -code: { - type: DataTypes.TEXT, - - - - }, - -address: { - type: DataTypes.TEXT, - - - - }, - -phone: { - type: DataTypes.TEXT, - - - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -mascot: { - type: DataTypes.TEXT, - - - - }, - -color: { - type: DataTypes.TEXT, - - - - }, - -bgGradient: { - type: DataTypes.TEXT, - - - - }, - -borderColor: { - type: DataTypes.TEXT, - - - - }, - -textColor: { - type: DataTypes.TEXT, - - - - }, - -bgLight: { - type: DataTypes.TEXT, - - - - }, - -description: { - type: DataTypes.TEXT, - - - - }, - -isOnline: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - }, - -active: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - campuses.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - db.campuses.hasMany(db.students, { - as: 'students_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.campuses.hasMany(db.staff, { - as: 'staff_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - db.campuses.hasMany(db.classes, { - as: 'classes_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - - db.campuses.hasMany(db.timetables, { - as: 'timetables_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.campuses.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - - db.campuses.hasMany(db.invoices, { - as: 'invoices_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - - - db.campuses.hasMany(db.messages, { - as: 'messages_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.campuses.hasMany(db.documents, { - as: 'documents_campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - -//end loop - - - - db.campuses.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - - - db.campuses.belongsTo(db.users, { - as: 'createdBy', - }); - - db.campuses.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return campuses; -}; - diff --git a/backend/src/db/models/campuses.ts b/backend/src/db/models/campuses.ts new file mode 100644 index 0000000..1b4b0a9 --- /dev/null +++ b/backend/src/db/models/campuses.ts @@ -0,0 +1,190 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Classes } from './classes'; +import type { Documents } from './documents'; +import type { Invoices } from './invoices'; +import type { Messages } from './messages'; +import type { Organizations } from './organizations'; +import type { Staff } from './staff'; +import type { Students } from './students'; +import type { Timetables } from './timetables'; +import type { Users } from './users'; + +export class Campuses extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string; + declare code: string; + declare address: string | null; + declare phone: string | null; + declare email: string | null; + declare mascot: string | null; + declare color: string | null; + declare bgGradient: string | null; + declare borderColor: string | null; + declare textColor: string | null; + declare bgLight: string | null; + declare description: string | null; + declare isOnline: CreationOptional; + declare active: CreationOptional; + declare importHash: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + + declare getStudents_campus: HasManyGetAssociationsMixin; + declare setStudents_campus: HasManySetAssociationsMixin; + declare getStaff_campus: HasManyGetAssociationsMixin; + declare setStaff_campus: HasManySetAssociationsMixin; + declare getClasses_campus: HasManyGetAssociationsMixin; + declare setClasses_campus: HasManySetAssociationsMixin; + declare getTimetables_campus: HasManyGetAssociationsMixin; + declare setTimetables_campus: HasManySetAssociationsMixin; + declare getAttendance_sessions_campus: HasManyGetAssociationsMixin; + declare setAttendance_sessions_campus: HasManySetAssociationsMixin; + declare getInvoices_campus: HasManyGetAssociationsMixin; + declare setInvoices_campus: HasManySetAssociationsMixin; + declare getMessages_campus: HasManyGetAssociationsMixin; + declare setMessages_campus: HasManySetAssociationsMixin; + declare getDocuments_campus: HasManyGetAssociationsMixin; + declare setDocuments_campus: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.campuses.hasMany(db.students, { + as: 'students_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.staff, { + as: 'staff_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.classes, { + as: 'classes_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.timetables, { + as: 'timetables_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.attendance_sessions, { + as: 'attendance_sessions_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.invoices, { + as: 'invoices_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.messages, { + as: 'messages_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.hasMany(db.documents, { + as: 'documents_campus', + foreignKey: { name: 'campusId' }, + constraints: false, + }); + + db.campuses.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { name: 'organizationId' }, + constraints: false, + }); + + db.campuses.belongsTo(db.users, { as: 'createdBy' }); + db.campuses.belongsTo(db.users, { as: 'updatedBy' }); + } +} + +export default function (sequelize: Sequelize): typeof Campuses { + Campuses.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { type: DataTypes.TEXT, allowNull: false }, + code: { type: DataTypes.TEXT, allowNull: false }, + address: { type: DataTypes.TEXT }, + phone: { type: DataTypes.TEXT }, + email: { type: DataTypes.TEXT }, + mascot: { type: DataTypes.TEXT }, + color: { type: DataTypes.TEXT }, + bgGradient: { type: DataTypes.TEXT }, + borderColor: { type: DataTypes.TEXT }, + textColor: { type: DataTypes.TEXT }, + bgLight: { type: DataTypes.TEXT }, + description: { type: DataTypes.TEXT }, + isOnline: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + }, + { + sequelize, + modelName: 'campuses', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Campuses; +} diff --git a/backend/src/db/models/class_enrollments.js b/backend/src/db/models/class_enrollments.js deleted file mode 100644 index d1feca5..0000000 --- a/backend/src/db/models/class_enrollments.js +++ /dev/null @@ -1,141 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const class_enrollments = sequelize.define( - 'class_enrollments', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -enrolled_on: { - type: DataTypes.DATE, - - - - }, - -ended_on: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"active", - - -"dropped", - - -"completed" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - class_enrollments.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.class_enrollments.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.class_enrollments.belongsTo(db.classes, { - as: 'class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - db.class_enrollments.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.class_enrollments.belongsTo(db.users, { - as: 'createdBy', - }); - - db.class_enrollments.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return class_enrollments; -}; - - diff --git a/backend/src/db/models/class_enrollments.ts b/backend/src/db/models/class_enrollments.ts new file mode 100644 index 0000000..7aa59a0 --- /dev/null +++ b/backend/src/db/models/class_enrollments.ts @@ -0,0 +1,189 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Classes } from './classes'; +import type { Organizations } from './organizations'; +import type { Students } from './students'; +import type { Users } from './users'; + +export class ClassEnrollments extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare enrolled_on: Date | null; + declare ended_on: Date | null; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare classId: CreationOptional; + declare studentId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getClass: BelongsToGetAssociationMixin; + declare setClass: BelongsToSetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare setStudent: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.class_enrollments.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.class_enrollments.belongsTo(db.classes, { + as: 'class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + db.class_enrollments.belongsTo(db.students, { + as: 'student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.class_enrollments.belongsTo(db.users, { + as: 'createdBy', + }); + + db.class_enrollments.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof ClassEnrollments { + ClassEnrollments.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +enrolled_on: { + type: DataTypes.DATE, + + + + }, + +ended_on: { + type: DataTypes.DATE, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"active", + + +"dropped", + + +"completed" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, + studentId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'class_enrollments', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return ClassEnrollments; +} diff --git a/backend/src/db/models/class_subjects.js b/backend/src/db/models/class_subjects.js deleted file mode 100644 index 691b88a..0000000 --- a/backend/src/db/models/class_subjects.js +++ /dev/null @@ -1,156 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const class_subjects = sequelize.define( - 'class_subjects', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"active", - - -"archived" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - class_subjects.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - db.class_subjects.hasMany(db.timetable_periods, { - as: 'timetable_periods_class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - - db.class_subjects.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - - - - - - db.class_subjects.hasMany(db.assessments, { - as: 'assessments_class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - - - - - - -//end loop - - - - db.class_subjects.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.class_subjects.belongsTo(db.classes, { - as: 'class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - db.class_subjects.belongsTo(db.subjects, { - as: 'subject', - foreignKey: { - name: 'subjectId', - }, - constraints: false, - }); - - db.class_subjects.belongsTo(db.staff, { - as: 'teacher', - foreignKey: { - name: 'teacherId', - }, - constraints: false, - }); - - - - - db.class_subjects.belongsTo(db.users, { - as: 'createdBy', - }); - - db.class_subjects.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return class_subjects; -}; - - diff --git a/backend/src/db/models/class_subjects.ts b/backend/src/db/models/class_subjects.ts new file mode 100644 index 0000000..233c2a2 --- /dev/null +++ b/backend/src/db/models/class_subjects.ts @@ -0,0 +1,218 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Assessments } from './assessments'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Classes } from './classes'; +import type { Organizations } from './organizations'; +import type { Staff } from './staff'; +import type { Subjects } from './subjects'; +import type { TimetablePeriods } from './timetable_periods'; +import type { Users } from './users'; + +export class ClassSubjects extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare classId: CreationOptional; + declare subjectId: CreationOptional; + declare teacherId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getTimetable_periods_class_subject: HasManyGetAssociationsMixin; + declare setTimetable_periods_class_subject: HasManySetAssociationsMixin; + declare getAttendance_sessions_class_subject: HasManyGetAssociationsMixin; + declare setAttendance_sessions_class_subject: HasManySetAssociationsMixin; + declare getAssessments_class_subject: HasManyGetAssociationsMixin; + declare setAssessments_class_subject: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getClass: BelongsToGetAssociationMixin; + declare setClass: BelongsToSetAssociationMixin; + declare getSubject: BelongsToGetAssociationMixin; + declare setSubject: BelongsToSetAssociationMixin; + declare getTeacher: BelongsToGetAssociationMixin; + declare setTeacher: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + db.class_subjects.hasMany(db.timetable_periods, { + as: 'timetable_periods_class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + + db.class_subjects.hasMany(db.attendance_sessions, { + as: 'attendance_sessions_class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + + + + + + db.class_subjects.hasMany(db.assessments, { + as: 'assessments_class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + + + + + + +//end loop + + + + db.class_subjects.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.class_subjects.belongsTo(db.classes, { + as: 'class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + db.class_subjects.belongsTo(db.subjects, { + as: 'subject', + foreignKey: { + name: 'subjectId', + }, + constraints: false, + }); + + db.class_subjects.belongsTo(db.staff, { + as: 'teacher', + foreignKey: { + name: 'teacherId', + }, + constraints: false, + }); + + + + + db.class_subjects.belongsTo(db.users, { + as: 'createdBy', + }); + + db.class_subjects.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof ClassSubjects { + ClassSubjects.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"active", + + +"archived" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + classId: { type: DataTypes.UUID, allowNull: true }, + subjectId: { type: DataTypes.UUID, allowNull: true }, + teacherId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'class_subjects', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return ClassSubjects; +} diff --git a/backend/src/db/models/classes.js b/backend/src/db/models/classes.js deleted file mode 100644 index 23396b8..0000000 --- a/backend/src/db/models/classes.js +++ /dev/null @@ -1,185 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const classes = sequelize.define( - 'classes', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -section: { - type: DataTypes.TEXT, - - - - }, - -capacity: { - type: DataTypes.INTEGER, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"active", - - -"archived" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - classes.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - db.classes.hasMany(db.class_enrollments, { - as: 'class_enrollments_class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - - db.classes.hasMany(db.class_subjects, { - as: 'class_subjects_class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - - - - db.classes.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_class', - foreignKey: { - name: 'classId', - }, - constraints: false, - }); - - - - - - - - - - - - -//end loop - - - - db.classes.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.classes.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.classes.belongsTo(db.academic_years, { - as: 'academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - db.classes.belongsTo(db.grades, { - as: 'grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - - db.classes.belongsTo(db.staff, { - as: 'homeroom_teacher', - foreignKey: { - name: 'homeroom_teacherId', - }, - constraints: false, - }); - - - - - db.classes.belongsTo(db.users, { - as: 'createdBy', - }); - - db.classes.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return classes; -}; - - diff --git a/backend/src/db/models/classes.ts b/backend/src/db/models/classes.ts new file mode 100644 index 0000000..e390633 --- /dev/null +++ b/backend/src/db/models/classes.ts @@ -0,0 +1,255 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AcademicYears } from './academic_years'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Campuses } from './campuses'; +import type { ClassEnrollments } from './class_enrollments'; +import type { ClassSubjects } from './class_subjects'; +import type { Grades } from './grades'; +import type { Organizations } from './organizations'; +import type { Staff } from './staff'; +import type { Users } from './users'; + +export class Classes extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare section: string | null; + declare capacity: number | null; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare academic_yearId: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare gradeId: CreationOptional; + declare homeroom_teacherId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getClass_enrollments_class: HasManyGetAssociationsMixin; + declare setClass_enrollments_class: HasManySetAssociationsMixin; + declare getClass_subjects_class: HasManyGetAssociationsMixin; + declare setClass_subjects_class: HasManySetAssociationsMixin; + declare getAttendance_sessions_class: HasManyGetAssociationsMixin; + declare setAttendance_sessions_class: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getAcademic_year: BelongsToGetAssociationMixin; + declare setAcademic_year: BelongsToSetAssociationMixin; + declare getGrade: BelongsToGetAssociationMixin; + declare setGrade: BelongsToSetAssociationMixin; + declare getHomeroom_teacher: BelongsToGetAssociationMixin; + declare setHomeroom_teacher: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + db.classes.hasMany(db.class_enrollments, { + as: 'class_enrollments_class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + + db.classes.hasMany(db.class_subjects, { + as: 'class_subjects_class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + + + + db.classes.hasMany(db.attendance_sessions, { + as: 'attendance_sessions_class', + foreignKey: { + name: 'classId', + }, + constraints: false, + }); + + + + + + + + + + + + +//end loop + + + + db.classes.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.classes.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.classes.belongsTo(db.academic_years, { + as: 'academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + db.classes.belongsTo(db.grades, { + as: 'grade', + foreignKey: { + name: 'gradeId', + }, + constraints: false, + }); + + db.classes.belongsTo(db.staff, { + as: 'homeroom_teacher', + foreignKey: { + name: 'homeroom_teacherId', + }, + constraints: false, + }); + + + + + db.classes.belongsTo(db.users, { + as: 'createdBy', + }); + + db.classes.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Classes { + Classes.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +section: { + type: DataTypes.TEXT, + + + + }, + +capacity: { + type: DataTypes.INTEGER, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"active", + + +"archived" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + academic_yearId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + gradeId: { type: DataTypes.UUID, allowNull: true }, + homeroom_teacherId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'classes', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Classes; +} diff --git a/backend/src/db/models/communication_events.js b/backend/src/db/models/communication_events.js deleted file mode 100644 index 20ba340..0000000 --- a/backend/src/db/models/communication_events.js +++ /dev/null @@ -1,67 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const communication_events = sequelize.define( - 'communication_events', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - title: { - type: DataTypes.TEXT, - allowNull: false, - }, - event_date: { - type: DataTypes.DATEONLY, - allowNull: false, - }, - event_type: { - type: DataTypes.TEXT, - allowNull: false, - }, - roles: { - type: DataTypes.JSONB, - allowNull: false, - defaultValue: [], - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - communication_events.associate = (db) => { - db.communication_events.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.communication_events.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.communication_events.belongsTo(db.users, { - as: 'createdBy', - }); - - db.communication_events.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return communication_events; -}; diff --git a/backend/src/db/models/communication_events.ts b/backend/src/db/models/communication_events.ts new file mode 100644 index 0000000..2c26d94 --- /dev/null +++ b/backend/src/db/models/communication_events.ts @@ -0,0 +1,126 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import { + COMMUNICATION_EVENT_TYPE_VALUES, + type CommunicationEventType, +} from '@/shared/constants/communications'; +import type { ProductRoleValue } from '@/shared/constants/roles'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class CommunicationEvents extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare title: string; + declare event_date: string; + declare event_type: CommunicationEventType; + declare roles: CreationOptional; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.communication_events.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.communication_events.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.communication_events.belongsTo(db.users, { + as: 'createdBy', + }); + + db.communication_events.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof CommunicationEvents { + CommunicationEvents.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + title: { + type: DataTypes.TEXT, + allowNull: false, + }, + event_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + event_type: { + type: DataTypes.ENUM(...COMMUNICATION_EVENT_TYPE_VALUES), + allowNull: false, + }, + roles: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'communication_events', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return CommunicationEvents; +} diff --git a/backend/src/db/models/content_catalog.js b/backend/src/db/models/content_catalog.js deleted file mode 100644 index 95816d4..0000000 --- a/backend/src/db/models/content_catalog.js +++ /dev/null @@ -1,38 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const content_catalog = sequelize.define( - 'content_catalog', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - content_type: { - type: DataTypes.TEXT, - allowNull: false, - unique: true, - }, - payload: { - type: DataTypes.JSONB, - allowNull: false, - }, - active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - return content_catalog; -}; diff --git a/backend/src/db/models/content_catalog.ts b/backend/src/db/models/content_catalog.ts new file mode 100644 index 0000000..acd6abc --- /dev/null +++ b/backend/src/db/models/content_catalog.ts @@ -0,0 +1,70 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; + +export class ContentCatalog extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare content_type: string; + declare payload: unknown; + declare active: CreationOptional; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + static associate(_db: Db): void { + + } +} + +export default function (sequelize: Sequelize): typeof ContentCatalog { + ContentCatalog.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + content_type: { + type: DataTypes.TEXT, + allowNull: false, + unique: true, + }, + payload: { + type: DataTypes.JSONB, + allowNull: false, + }, + active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + }, + { + sequelize, + modelName: 'content_catalog', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return ContentCatalog; +} diff --git a/backend/src/db/models/documents.js b/backend/src/db/models/documents.js deleted file mode 100644 index ca4d307..0000000 --- a/backend/src/db/models/documents.js +++ /dev/null @@ -1,203 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const documents = sequelize.define( - 'documents', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -entity_type: { - type: DataTypes.ENUM, - - - - values: [ - -"student", - - -"staff", - - -"class", - - -"invoice", - - -"organization", - - -"campus", - - -"other" - - ], - - }, - -entity_reference: { - type: DataTypes.TEXT, - - - - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -category: { - type: DataTypes.ENUM, - - - - values: [ - -"policy", - - -"report", - - -"id", - - -"medical", - - -"consent", - - -"invoice", - - -"receipt", - - -"other" - - ], - - }, - -uploaded_at: { - type: DataTypes.DATE, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - documents.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.documents.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.documents.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.documents.hasMany(db.file, { - as: 'file', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.documents.getTableName(), - belongsToColumn: 'file', - }, - }); - - - db.documents.belongsTo(db.users, { - as: 'createdBy', - }); - - db.documents.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return documents; -}; - - diff --git a/backend/src/db/models/documents.ts b/backend/src/db/models/documents.ts new file mode 100644 index 0000000..38584c2 --- /dev/null +++ b/backend/src/db/models/documents.ts @@ -0,0 +1,254 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { File } from './file'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Documents extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare entity_type: string | null; + declare entity_reference: string | null; + declare name: string | null; + declare category: string | null; + declare uploaded_at: Date | null; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getFile: HasManyGetAssociationsMixin; + declare setFile: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.documents.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.documents.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + + + db.documents.hasMany(db.file, { + as: 'file', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.documents.getTableName(), + belongsToColumn: 'file', + }, + }); + + + db.documents.belongsTo(db.users, { + as: 'createdBy', + }); + + db.documents.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Documents { + Documents.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +entity_type: { + type: DataTypes.ENUM, + + + + values: [ + +"student", + + +"staff", + + +"class", + + +"invoice", + + +"organization", + + +"campus", + + +"other" + + ], + + }, + +entity_reference: { + type: DataTypes.TEXT, + + + + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +category: { + type: DataTypes.ENUM, + + + + values: [ + +"policy", + + +"report", + + +"id", + + +"medical", + + +"consent", + + +"invoice", + + +"receipt", + + +"other" + + ], + + }, + +uploaded_at: { + type: DataTypes.DATE, + + + + }, + +notes: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'documents', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Documents; +} diff --git a/backend/src/db/models/fee_plans.js b/backend/src/db/models/fee_plans.js deleted file mode 100644 index 5ec8547..0000000 --- a/backend/src/db/models/fee_plans.js +++ /dev/null @@ -1,169 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const fee_plans = sequelize.define( - 'fee_plans', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -billing_cycle: { - type: DataTypes.ENUM, - - - - values: [ - -"one_time", - - -"monthly", - - -"termly", - - -"annual" - - ], - - }, - -total_amount: { - type: DataTypes.DECIMAL, - - - - }, - -active: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - fee_plans.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - db.fee_plans.hasMany(db.invoices, { - as: 'invoices_fee_plan', - foreignKey: { - name: 'fee_planId', - }, - constraints: false, - }); - - - - - - - - - -//end loop - - - - db.fee_plans.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.fee_plans.belongsTo(db.academic_years, { - as: 'academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - db.fee_plans.belongsTo(db.grades, { - as: 'grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - - - - - db.fee_plans.belongsTo(db.users, { - as: 'createdBy', - }); - - db.fee_plans.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return fee_plans; -}; - - diff --git a/backend/src/db/models/fee_plans.ts b/backend/src/db/models/fee_plans.ts new file mode 100644 index 0000000..f38eb8e --- /dev/null +++ b/backend/src/db/models/fee_plans.ts @@ -0,0 +1,224 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AcademicYears } from './academic_years'; +import type { Grades } from './grades'; +import type { Invoices } from './invoices'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class FeePlans extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare billing_cycle: string | null; + declare total_amount: string | null; + declare active: CreationOptional; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare academic_yearId: CreationOptional; + declare organizationId: CreationOptional; + declare gradeId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getInvoices_fee_plan: HasManyGetAssociationsMixin; + declare setInvoices_fee_plan: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getAcademic_year: BelongsToGetAssociationMixin; + declare setAcademic_year: BelongsToSetAssociationMixin; + declare getGrade: BelongsToGetAssociationMixin; + declare setGrade: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + db.fee_plans.hasMany(db.invoices, { + as: 'invoices_fee_plan', + foreignKey: { + name: 'fee_planId', + }, + constraints: false, + }); + + + + + + + + + +//end loop + + + + db.fee_plans.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.fee_plans.belongsTo(db.academic_years, { + as: 'academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + db.fee_plans.belongsTo(db.grades, { + as: 'grade', + foreignKey: { + name: 'gradeId', + }, + constraints: false, + }); + + + + + db.fee_plans.belongsTo(db.users, { + as: 'createdBy', + }); + + db.fee_plans.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof FeePlans { + FeePlans.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +billing_cycle: { + type: DataTypes.ENUM, + + + + values: [ + +"one_time", + + +"monthly", + + +"termly", + + +"annual" + + ], + + }, + +total_amount: { + type: DataTypes.DECIMAL, + + + + }, + +active: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + +notes: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + academic_yearId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + gradeId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'fee_plans', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return FeePlans; +} diff --git a/backend/src/db/models/file.js b/backend/src/db/models/file.js deleted file mode 100644 index 7703bb6..0000000 --- a/backend/src/db/models/file.js +++ /dev/null @@ -1,53 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const file = sequelize.define( - 'file', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - belongsTo: DataTypes.STRING(255), - belongsToId: DataTypes.UUID, - belongsToColumn: DataTypes.STRING(255), - name: { - type: DataTypes.STRING(2083), - allowNull: false, - validate: { - notEmpty: true, - }, - }, - sizeInBytes: { - type: DataTypes.INTEGER, - allowNull: true, - }, - privateUrl: { - type: DataTypes.STRING(2083), - allowNull: true, - }, - publicUrl: { - type: DataTypes.STRING(2083), - allowNull: false, - validate: { - notEmpty: true, - }, - }, - }, - { - timestamps: true, - paranoid: true, - }, - ); - - file.associate = (db) => { - db.file.belongsTo(db.users, { - as: 'createdBy', - }); - - db.file.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return file; -}; diff --git a/backend/src/db/models/file.ts b/backend/src/db/models/file.ts new file mode 100644 index 0000000..a056cf3 --- /dev/null +++ b/backend/src/db/models/file.ts @@ -0,0 +1,99 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Users } from './users'; + +export class File extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare belongsTo: string | null; + declare belongsToId: string | null; + declare belongsToColumn: string | null; + declare name: string; + declare sizeInBytes: number | null; + declare privateUrl: string | null; + declare publicUrl: string; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.file.belongsTo(db.users, { + as: 'createdBy', + }); + + db.file.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof File { + File.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + belongsTo: DataTypes.STRING(255), + belongsToId: DataTypes.UUID, + belongsToColumn: DataTypes.STRING(255), + name: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + sizeInBytes: { + type: DataTypes.INTEGER, + allowNull: true, + }, + privateUrl: { + type: DataTypes.STRING(2083), + allowNull: true, + }, + publicUrl: { + type: DataTypes.STRING(2083), + allowNull: false, + validate: { + notEmpty: true, + }, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'file', + timestamps: true, + paranoid: true, + }, + ); + + return File; +} diff --git a/backend/src/db/models/frame_entries.js b/backend/src/db/models/frame_entries.js deleted file mode 100644 index bf51c2e..0000000 --- a/backend/src/db/models/frame_entries.js +++ /dev/null @@ -1,82 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const frame_entries = sequelize.define( - 'frame_entries', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - week_of: { - type: DataTypes.TEXT, - allowNull: false, - }, - posted_date: { - type: DataTypes.TEXT, - allowNull: false, - }, - formal: { - type: DataTypes.TEXT, - allowNull: false, - }, - recognition: { - type: DataTypes.TEXT, - allowNull: false, - }, - application: { - type: DataTypes.TEXT, - allowNull: false, - }, - management: { - type: DataTypes.TEXT, - allowNull: false, - }, - emotional: { - type: DataTypes.TEXT, - allowNull: false, - }, - author: { - type: DataTypes.TEXT, - allowNull: false, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - frame_entries.associate = (db) => { - db.frame_entries.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.frame_entries.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.frame_entries.belongsTo(db.users, { - as: 'createdBy', - }); - - db.frame_entries.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return frame_entries; -}; diff --git a/backend/src/db/models/frame_entries.ts b/backend/src/db/models/frame_entries.ts new file mode 100644 index 0000000..d0078c7 --- /dev/null +++ b/backend/src/db/models/frame_entries.ts @@ -0,0 +1,140 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class FrameEntries extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare week_of: string; + declare posted_date: string; + declare formal: string; + declare recognition: string; + declare application: string; + declare management: string; + declare emotional: string; + declare author: string; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.frame_entries.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.frame_entries.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.frame_entries.belongsTo(db.users, { + as: 'createdBy', + }); + + db.frame_entries.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof FrameEntries { + FrameEntries.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + week_of: { + type: DataTypes.TEXT, + allowNull: false, + }, + posted_date: { + type: DataTypes.TEXT, + allowNull: false, + }, + formal: { + type: DataTypes.TEXT, + allowNull: false, + }, + recognition: { + type: DataTypes.TEXT, + allowNull: false, + }, + application: { + type: DataTypes.TEXT, + allowNull: false, + }, + management: { + type: DataTypes.TEXT, + allowNull: false, + }, + emotional: { + type: DataTypes.TEXT, + allowNull: false, + }, + author: { + type: DataTypes.TEXT, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'frame_entries', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return FrameEntries; +} diff --git a/backend/src/db/models/grades.js b/backend/src/db/models/grades.js deleted file mode 100644 index 6123a5f..0000000 --- a/backend/src/db/models/grades.js +++ /dev/null @@ -1,136 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const grades = sequelize.define( - 'grades', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -code: { - type: DataTypes.TEXT, - - - - }, - -sort_order: { - type: DataTypes.INTEGER, - - - - }, - -description: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - grades.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - db.grades.hasMany(db.classes, { - as: 'classes_grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - - - - - - - - - db.grades.hasMany(db.fee_plans, { - as: 'fee_plans_grade', - foreignKey: { - name: 'gradeId', - }, - constraints: false, - }); - - - - - - - - - - -//end loop - - - - db.grades.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - - - db.grades.belongsTo(db.users, { - as: 'createdBy', - }); - - db.grades.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return grades; -}; - - diff --git a/backend/src/db/models/grades.ts b/backend/src/db/models/grades.ts new file mode 100644 index 0000000..55aee6b --- /dev/null +++ b/backend/src/db/models/grades.ts @@ -0,0 +1,183 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Classes } from './classes'; +import type { FeePlans } from './fee_plans'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Grades extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare code: string | null; + declare sort_order: number | null; + declare description: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getClasses_grade: HasManyGetAssociationsMixin; + declare setClasses_grade: HasManySetAssociationsMixin; + declare getFee_plans_grade: HasManyGetAssociationsMixin; + declare setFee_plans_grade: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + db.grades.hasMany(db.classes, { + as: 'classes_grade', + foreignKey: { + name: 'gradeId', + }, + constraints: false, + }); + + + + + + + + + db.grades.hasMany(db.fee_plans, { + as: 'fee_plans_grade', + foreignKey: { + name: 'gradeId', + }, + constraints: false, + }); + + + + + + + + + + +//end loop + + + + db.grades.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + + + db.grades.belongsTo(db.users, { + as: 'createdBy', + }); + + db.grades.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Grades { + Grades.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +code: { + type: DataTypes.TEXT, + + + + }, + +sort_order: { + type: DataTypes.INTEGER, + + + + }, + +description: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'grades', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Grades; +} diff --git a/backend/src/db/models/guardians.js b/backend/src/db/models/guardians.js deleted file mode 100644 index ecb58a7..0000000 --- a/backend/src/db/models/guardians.js +++ /dev/null @@ -1,160 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const guardians = sequelize.define( - 'guardians', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -full_name: { - type: DataTypes.TEXT, - - - - }, - -relationship: { - type: DataTypes.ENUM, - - - - values: [ - -"mother", - - -"father", - - -"guardian", - - -"other" - - ], - - }, - -phone: { - type: DataTypes.TEXT, - - - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -address: { - type: DataTypes.TEXT, - - - - }, - -primary_contact: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - guardians.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.guardians.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.guardians.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.guardians.belongsTo(db.users, { - as: 'createdBy', - }); - - db.guardians.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return guardians; -}; - - diff --git a/backend/src/db/models/guardians.ts b/backend/src/db/models/guardians.ts new file mode 100644 index 0000000..948d589 --- /dev/null +++ b/backend/src/db/models/guardians.ts @@ -0,0 +1,206 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Organizations } from './organizations'; +import type { Students } from './students'; +import type { Users } from './users'; + +export class Guardians extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare full_name: string | null; + declare relationship: string | null; + declare phone: string | null; + declare email: string | null; + declare address: string | null; + declare primary_contact: CreationOptional; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare studentId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare setStudent: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.guardians.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.guardians.belongsTo(db.students, { + as: 'student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.guardians.belongsTo(db.users, { + as: 'createdBy', + }); + + db.guardians.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Guardians { + Guardians.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +full_name: { + type: DataTypes.TEXT, + + + + }, + +relationship: { + type: DataTypes.ENUM, + + + + values: [ + +"mother", + + +"father", + + +"guardian", + + +"other" + + ], + + }, + +phone: { + type: DataTypes.TEXT, + + + + }, + +email: { + type: DataTypes.TEXT, + + + + }, + +address: { + type: DataTypes.TEXT, + + + + }, + +primary_contact: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + studentId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'guardians', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Guardians; +} diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js deleted file mode 100644 index 4a3852f..0000000 --- a/backend/src/db/models/index.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const Sequelize = require('sequelize'); -const basename = path.basename(__filename); -const env = process.env.NODE_ENV || 'development'; -const config = require("../db.config")[env]; -const db = {}; - -let sequelize; -console.log(env); -if (config.use_env_variable) { - sequelize = new Sequelize(process.env[config.use_env_variable], config); -} else { - sequelize = new Sequelize(config.database, config.username, config.password, config); -} - -fs - .readdirSync(__dirname) - .filter(file => { - return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); - }) - .forEach(file => { - const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes) - db[model.name] = model; - }); - -Object.keys(db).forEach(modelName => { - if (db[modelName].associate) { - db[modelName].associate(db); - } -}); - -db.sequelize = sequelize; -db.Sequelize = Sequelize; - -module.exports = db; diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts new file mode 100644 index 0000000..d0be5e3 --- /dev/null +++ b/backend/src/db/models/index.ts @@ -0,0 +1,133 @@ +import { Sequelize } from 'sequelize'; +import dbConfig from '@/db/db.config'; +import { + DEFAULT_DEV_DB_HOST, + DEFAULT_DEV_DB_NAME, + DEFAULT_DEV_DB_USER, +} from '@/shared/constants/app'; + +import academic_years from './academic_years'; +import assessment_results from './assessment_results'; +import assessments from './assessments'; +import attendance_records from './attendance_records'; +import attendance_sessions from './attendance_sessions'; +import auth_refresh_tokens from './auth_refresh_tokens'; +import campus_attendance_config from './campus_attendance_config'; +import campus_attendance_summaries from './campus_attendance_summaries'; +import campuses from './campuses'; +import class_enrollments from './class_enrollments'; +import class_subjects from './class_subjects'; +import classes from './classes'; +import communication_events from './communication_events'; +import content_catalog from './content_catalog'; +import documents from './documents'; +import fee_plans from './fee_plans'; +import file from './file'; +import frame_entries from './frame_entries'; +import grades from './grades'; +import guardians from './guardians'; +import invoices from './invoices'; +import message_recipients from './message_recipients'; +import messages from './messages'; +import organizations from './organizations'; +import payments from './payments'; +import permissions from './permissions'; +import personality_quiz_results from './personality_quiz_results'; +import roles from './roles'; +import safety_quiz_results from './safety_quiz_results'; +import staff from './staff'; +import staff_attendance_records from './staff_attendance_records'; +import students from './students'; +import subjects from './subjects'; +import timetable_periods from './timetable_periods'; +import timetables from './timetables'; +import user_progress from './user_progress'; +import walkthrough_checkins from './walkthrough_checkins'; +import users from './users'; + +const env = process.env.NODE_ENV || 'development'; + +function selectConnection(): { + database?: string; + username?: string; + password?: string; + host?: string; + port?: string; +} { + if (env === 'production') { + return dbConfig.production; + } + + if (env === 'dev_stage') { + return dbConfig.dev_stage; + } + + return dbConfig.development; +} + +const connection = selectConnection(); + +const sequelize = new Sequelize( + connection.database ?? DEFAULT_DEV_DB_NAME, + connection.username ?? DEFAULT_DEV_DB_USER, + connection.password ?? '', + { + dialect: 'postgres', + host: connection.host ?? DEFAULT_DEV_DB_HOST, + port: connection.port ? Number(connection.port) : undefined, + logging: console.log, + }, +); + +const models = { + academic_years: academic_years(sequelize), + assessment_results: assessment_results(sequelize), + assessments: assessments(sequelize), + attendance_records: attendance_records(sequelize), + attendance_sessions: attendance_sessions(sequelize), + auth_refresh_tokens: auth_refresh_tokens(sequelize), + campus_attendance_config: campus_attendance_config(sequelize), + campus_attendance_summaries: campus_attendance_summaries(sequelize), + campuses: campuses(sequelize), + class_enrollments: class_enrollments(sequelize), + class_subjects: class_subjects(sequelize), + classes: classes(sequelize), + communication_events: communication_events(sequelize), + content_catalog: content_catalog(sequelize), + documents: documents(sequelize), + fee_plans: fee_plans(sequelize), + file: file(sequelize), + frame_entries: frame_entries(sequelize), + grades: grades(sequelize), + guardians: guardians(sequelize), + invoices: invoices(sequelize), + message_recipients: message_recipients(sequelize), + messages: messages(sequelize), + organizations: organizations(sequelize), + payments: payments(sequelize), + permissions: permissions(sequelize), + personality_quiz_results: personality_quiz_results(sequelize), + roles: roles(sequelize), + safety_quiz_results: safety_quiz_results(sequelize), + staff: staff(sequelize), + staff_attendance_records: staff_attendance_records(sequelize), + students: students(sequelize), + subjects: subjects(sequelize), + timetable_periods: timetable_periods(sequelize), + timetables: timetables(sequelize), + user_progress: user_progress(sequelize), + walkthrough_checkins: walkthrough_checkins(sequelize), + users: users(sequelize), +}; + +for (const model of Object.values(models)) { + model.associate?.(models); +} + +const db = { + ...models, + sequelize, + Sequelize, +}; + +export default db; diff --git a/backend/src/db/models/invoices.js b/backend/src/db/models/invoices.js deleted file mode 100644 index ff546c8..0000000 --- a/backend/src/db/models/invoices.js +++ /dev/null @@ -1,225 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const invoices = sequelize.define( - 'invoices', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -invoice_number: { - type: DataTypes.TEXT, - - - - }, - -issue_date: { - type: DataTypes.DATE, - - - - }, - -due_date: { - type: DataTypes.DATE, - - - - }, - -subtotal: { - type: DataTypes.DECIMAL, - - - - }, - -discount_amount: { - type: DataTypes.DECIMAL, - - - - }, - -tax_amount: { - type: DataTypes.DECIMAL, - - - - }, - -total_amount: { - type: DataTypes.DECIMAL, - - - - }, - -balance_due: { - type: DataTypes.DECIMAL, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"draft", - - -"issued", - - -"partially_paid", - - -"paid", - - -"overdue", - - -"void" - - ], - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - invoices.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - db.invoices.hasMany(db.payments, { - as: 'payments_invoice', - foreignKey: { - name: 'invoiceId', - }, - constraints: false, - }); - - - - - - - - -//end loop - - - - db.invoices.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.students, { - as: 'student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - db.invoices.belongsTo(db.fee_plans, { - as: 'fee_plan', - foreignKey: { - name: 'fee_planId', - }, - constraints: false, - }); - - - - db.invoices.hasMany(db.file, { - as: 'attachments', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.invoices.getTableName(), - belongsToColumn: 'attachments', - }, - }); - - - db.invoices.belongsTo(db.users, { - as: 'createdBy', - }); - - db.invoices.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return invoices; -}; - - diff --git a/backend/src/db/models/invoices.ts b/backend/src/db/models/invoices.ts new file mode 100644 index 0000000..e1337e7 --- /dev/null +++ b/backend/src/db/models/invoices.ts @@ -0,0 +1,293 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { FeePlans } from './fee_plans'; +import type { File } from './file'; +import type { Organizations } from './organizations'; +import type { Payments } from './payments'; +import type { Students } from './students'; +import type { Users } from './users'; + +export class Invoices extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare invoice_number: string | null; + declare issue_date: Date | null; + declare due_date: Date | null; + declare subtotal: string | null; + declare discount_amount: string | null; + declare tax_amount: string | null; + declare total_amount: string | null; + declare balance_due: string | null; + declare status: string | null; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare campusId: CreationOptional; + declare fee_planId: CreationOptional; + declare organizationId: CreationOptional; + declare studentId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getPayments_invoice: HasManyGetAssociationsMixin; + declare setPayments_invoice: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getStudent: BelongsToGetAssociationMixin; + declare setStudent: BelongsToSetAssociationMixin; + declare getFee_plan: BelongsToGetAssociationMixin; + declare setFee_plan: BelongsToSetAssociationMixin; + declare getAttachments: HasManyGetAssociationsMixin; + declare setAttachments: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + db.invoices.hasMany(db.payments, { + as: 'payments_invoice', + foreignKey: { + name: 'invoiceId', + }, + constraints: false, + }); + + + + + + + + +//end loop + + + + db.invoices.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.invoices.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.invoices.belongsTo(db.students, { + as: 'student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + db.invoices.belongsTo(db.fee_plans, { + as: 'fee_plan', + foreignKey: { + name: 'fee_planId', + }, + constraints: false, + }); + + + + db.invoices.hasMany(db.file, { + as: 'attachments', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.invoices.getTableName(), + belongsToColumn: 'attachments', + }, + }); + + + db.invoices.belongsTo(db.users, { + as: 'createdBy', + }); + + db.invoices.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Invoices { + Invoices.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +invoice_number: { + type: DataTypes.TEXT, + + + + }, + +issue_date: { + type: DataTypes.DATE, + + + + }, + +due_date: { + type: DataTypes.DATE, + + + + }, + +subtotal: { + type: DataTypes.DECIMAL, + + + + }, + +discount_amount: { + type: DataTypes.DECIMAL, + + + + }, + +tax_amount: { + type: DataTypes.DECIMAL, + + + + }, + +total_amount: { + type: DataTypes.DECIMAL, + + + + }, + +balance_due: { + type: DataTypes.DECIMAL, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"draft", + + +"issued", + + +"partially_paid", + + +"paid", + + +"overdue", + + +"void" + + ], + + }, + +notes: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + campusId: { type: DataTypes.UUID, allowNull: true }, + fee_planId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + studentId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'invoices', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Invoices; +} diff --git a/backend/src/db/models/message_recipients.js b/backend/src/db/models/message_recipients.js deleted file mode 100644 index 4de03a9..0000000 --- a/backend/src/db/models/message_recipients.js +++ /dev/null @@ -1,172 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const message_recipients = sequelize.define( - 'message_recipients', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -recipient_type: { - type: DataTypes.ENUM, - - - - values: [ - -"user", - - -"student", - - -"guardian" - - ], - - }, - -recipient_label: { - type: DataTypes.TEXT, - - - - }, - -destination: { - type: DataTypes.TEXT, - - - - }, - -delivery_status: { - type: DataTypes.ENUM, - - - - values: [ - -"pending", - - -"sent", - - -"delivered", - - -"failed", - - -"read" - - ], - - }, - -delivered_at: { - type: DataTypes.DATE, - - - - }, - -read_at: { - type: DataTypes.DATE, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - message_recipients.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.message_recipients.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.message_recipients.belongsTo(db.messages, { - as: 'message', - foreignKey: { - name: 'messageId', - }, - constraints: false, - }); - - - - - db.message_recipients.belongsTo(db.users, { - as: 'createdBy', - }); - - db.message_recipients.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return message_recipients; -}; - - diff --git a/backend/src/db/models/message_recipients.ts b/backend/src/db/models/message_recipients.ts new file mode 100644 index 0000000..86423cc --- /dev/null +++ b/backend/src/db/models/message_recipients.ts @@ -0,0 +1,218 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Messages } from './messages'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class MessageRecipients extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare recipient_type: string | null; + declare recipient_label: string | null; + declare destination: string | null; + declare delivery_status: string | null; + declare delivered_at: Date | null; + declare read_at: Date | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare messageId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getMessage: BelongsToGetAssociationMixin; + declare setMessage: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.message_recipients.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.message_recipients.belongsTo(db.messages, { + as: 'message', + foreignKey: { + name: 'messageId', + }, + constraints: false, + }); + + + + + db.message_recipients.belongsTo(db.users, { + as: 'createdBy', + }); + + db.message_recipients.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof MessageRecipients { + MessageRecipients.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +recipient_type: { + type: DataTypes.ENUM, + + + + values: [ + +"user", + + +"student", + + +"guardian" + + ], + + }, + +recipient_label: { + type: DataTypes.TEXT, + + + + }, + +destination: { + type: DataTypes.TEXT, + + + + }, + +delivery_status: { + type: DataTypes.ENUM, + + + + values: [ + +"pending", + + +"sent", + + +"delivered", + + +"failed", + + +"read" + + ], + + }, + +delivered_at: { + type: DataTypes.DATE, + + + + }, + +read_at: { + type: DataTypes.DATE, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + messageId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'message_recipients', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return MessageRecipients; +} diff --git a/backend/src/db/models/messages.js b/backend/src/db/models/messages.js deleted file mode 100644 index 758c994..0000000 --- a/backend/src/db/models/messages.js +++ /dev/null @@ -1,219 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const messages = sequelize.define( - 'messages', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -subject: { - type: DataTypes.TEXT, - - - - }, - -body: { - type: DataTypes.TEXT, - - - - }, - -channel: { - type: DataTypes.ENUM, - - - - values: [ - -"in_app", - - -"email", - - -"sms" - - ], - - }, - -audience: { - type: DataTypes.ENUM, - - - - values: [ - -"all_org", - - -"campus", - - -"class", - - -"staff", - - -"students", - - -"guardians", - - -"custom" - - ], - - }, - -sent_at: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"draft", - - -"scheduled", - - -"sent", - - -"failed" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - messages.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - db.messages.hasMany(db.message_recipients, { - as: 'message_recipients_message', - foreignKey: { - name: 'messageId', - }, - constraints: false, - }); - - - - -//end loop - - - - db.messages.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.messages.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.messages.belongsTo(db.users, { - as: 'sent_by', - foreignKey: { - name: 'sent_byId', - }, - constraints: false, - }); - - - - db.messages.hasMany(db.file, { - as: 'attachments', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.messages.getTableName(), - belongsToColumn: 'attachments', - }, - }); - - - db.messages.belongsTo(db.users, { - as: 'createdBy', - }); - - db.messages.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return messages; -}; - - diff --git a/backend/src/db/models/messages.ts b/backend/src/db/models/messages.ts new file mode 100644 index 0000000..8eb2cc4 --- /dev/null +++ b/backend/src/db/models/messages.ts @@ -0,0 +1,277 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { File } from './file'; +import type { MessageRecipients } from './message_recipients'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Messages extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare subject: string | null; + declare body: string | null; + declare channel: string | null; + declare audience: string | null; + declare sent_at: Date | null; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare sent_byId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getMessage_recipients_message: HasManyGetAssociationsMixin; + declare setMessage_recipients_message: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getSent_by: BelongsToGetAssociationMixin; + declare setSent_by: BelongsToSetAssociationMixin; + declare getAttachments: HasManyGetAssociationsMixin; + declare setAttachments: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + db.messages.hasMany(db.message_recipients, { + as: 'message_recipients_message', + foreignKey: { + name: 'messageId', + }, + constraints: false, + }); + + + + +//end loop + + + + db.messages.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.messages.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.messages.belongsTo(db.users, { + as: 'sent_by', + foreignKey: { + name: 'sent_byId', + }, + constraints: false, + }); + + + + db.messages.hasMany(db.file, { + as: 'attachments', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.messages.getTableName(), + belongsToColumn: 'attachments', + }, + }); + + + db.messages.belongsTo(db.users, { + as: 'createdBy', + }); + + db.messages.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Messages { + Messages.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +subject: { + type: DataTypes.TEXT, + + + + }, + +body: { + type: DataTypes.TEXT, + + + + }, + +channel: { + type: DataTypes.ENUM, + + + + values: [ + +"in_app", + + +"email", + + +"sms" + + ], + + }, + +audience: { + type: DataTypes.ENUM, + + + + values: [ + +"all_org", + + +"campus", + + +"class", + + +"staff", + + +"students", + + +"guardians", + + +"custom" + + ], + + }, + +sent_at: { + type: DataTypes.DATE, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"draft", + + +"scheduled", + + +"sent", + + +"failed" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + sent_byId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'messages', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Messages; +} diff --git a/backend/src/db/models/organizations.js b/backend/src/db/models/organizations.js deleted file mode 100644 index a74e7dc..0000000 --- a/backend/src/db/models/organizations.js +++ /dev/null @@ -1,275 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const organizations = sequelize.define( - 'organizations', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - organizations.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - db.organizations.hasMany(db.users, { - as: 'users_organizations', - foreignKey: { - name: 'organizationsId', - }, - constraints: false, - }); - - - - - - db.organizations.hasMany(db.campuses, { - as: 'campuses_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.academic_years, { - as: 'academic_years_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.grades, { - as: 'grades_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.subjects, { - as: 'subjects_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.students, { - as: 'students_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.guardians, { - as: 'guardians_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.staff, { - as: 'staff_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.classes, { - as: 'classes_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.class_enrollments, { - as: 'class_enrollments_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.class_subjects, { - as: 'class_subjects_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.timetables, { - as: 'timetables_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.timetable_periods, { - as: 'timetable_periods_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.attendance_records, { - as: 'attendance_records_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.fee_plans, { - as: 'fee_plans_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.invoices, { - as: 'invoices_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.payments, { - as: 'payments_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.assessments, { - as: 'assessments_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.assessment_results, { - as: 'assessment_results_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.messages, { - as: 'messages_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.message_recipients, { - as: 'message_recipients_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - db.organizations.hasMany(db.documents, { - as: 'documents_organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - -//end loop - - - - - - - db.organizations.belongsTo(db.users, { - as: 'createdBy', - }); - - db.organizations.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return organizations; -}; - - diff --git a/backend/src/db/models/organizations.ts b/backend/src/db/models/organizations.ts new file mode 100644 index 0000000..d8f3984 --- /dev/null +++ b/backend/src/db/models/organizations.ts @@ -0,0 +1,376 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AcademicYears } from './academic_years'; +import type { AssessmentResults } from './assessment_results'; +import type { Assessments } from './assessments'; +import type { AttendanceRecords } from './attendance_records'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Campuses } from './campuses'; +import type { ClassEnrollments } from './class_enrollments'; +import type { ClassSubjects } from './class_subjects'; +import type { Classes } from './classes'; +import type { Documents } from './documents'; +import type { FeePlans } from './fee_plans'; +import type { Grades } from './grades'; +import type { Guardians } from './guardians'; +import type { Invoices } from './invoices'; +import type { MessageRecipients } from './message_recipients'; +import type { Messages } from './messages'; +import type { Payments } from './payments'; +import type { Staff } from './staff'; +import type { Students } from './students'; +import type { Subjects } from './subjects'; +import type { TimetablePeriods } from './timetable_periods'; +import type { Timetables } from './timetables'; +import type { Users } from './users'; + +export class Organizations extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getUsers_organizations: HasManyGetAssociationsMixin; + declare setUsers_organizations: HasManySetAssociationsMixin; + declare getCampuses_organization: HasManyGetAssociationsMixin; + declare setCampuses_organization: HasManySetAssociationsMixin; + declare getAcademic_years_organization: HasManyGetAssociationsMixin; + declare setAcademic_years_organization: HasManySetAssociationsMixin; + declare getGrades_organization: HasManyGetAssociationsMixin; + declare setGrades_organization: HasManySetAssociationsMixin; + declare getSubjects_organization: HasManyGetAssociationsMixin; + declare setSubjects_organization: HasManySetAssociationsMixin; + declare getStudents_organization: HasManyGetAssociationsMixin; + declare setStudents_organization: HasManySetAssociationsMixin; + declare getGuardians_organization: HasManyGetAssociationsMixin; + declare setGuardians_organization: HasManySetAssociationsMixin; + declare getStaff_organization: HasManyGetAssociationsMixin; + declare setStaff_organization: HasManySetAssociationsMixin; + declare getClasses_organization: HasManyGetAssociationsMixin; + declare setClasses_organization: HasManySetAssociationsMixin; + declare getClass_enrollments_organization: HasManyGetAssociationsMixin; + declare setClass_enrollments_organization: HasManySetAssociationsMixin; + declare getClass_subjects_organization: HasManyGetAssociationsMixin; + declare setClass_subjects_organization: HasManySetAssociationsMixin; + declare getTimetables_organization: HasManyGetAssociationsMixin; + declare setTimetables_organization: HasManySetAssociationsMixin; + declare getTimetable_periods_organization: HasManyGetAssociationsMixin; + declare setTimetable_periods_organization: HasManySetAssociationsMixin; + declare getAttendance_sessions_organization: HasManyGetAssociationsMixin; + declare setAttendance_sessions_organization: HasManySetAssociationsMixin; + declare getAttendance_records_organization: HasManyGetAssociationsMixin; + declare setAttendance_records_organization: HasManySetAssociationsMixin; + declare getFee_plans_organization: HasManyGetAssociationsMixin; + declare setFee_plans_organization: HasManySetAssociationsMixin; + declare getInvoices_organization: HasManyGetAssociationsMixin; + declare setInvoices_organization: HasManySetAssociationsMixin; + declare getPayments_organization: HasManyGetAssociationsMixin; + declare setPayments_organization: HasManySetAssociationsMixin; + declare getAssessments_organization: HasManyGetAssociationsMixin; + declare setAssessments_organization: HasManySetAssociationsMixin; + declare getAssessment_results_organization: HasManyGetAssociationsMixin; + declare setAssessment_results_organization: HasManySetAssociationsMixin; + declare getMessages_organization: HasManyGetAssociationsMixin; + declare setMessages_organization: HasManySetAssociationsMixin; + declare getMessage_recipients_organization: HasManyGetAssociationsMixin; + declare setMessage_recipients_organization: HasManySetAssociationsMixin; + declare getDocuments_organization: HasManyGetAssociationsMixin; + declare setDocuments_organization: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + db.organizations.hasMany(db.users, { + as: 'users_organizations', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + + + + db.organizations.hasMany(db.campuses, { + as: 'campuses_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.academic_years, { + as: 'academic_years_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.grades, { + as: 'grades_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.subjects, { + as: 'subjects_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.students, { + as: 'students_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.guardians, { + as: 'guardians_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.staff, { + as: 'staff_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.classes, { + as: 'classes_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.class_enrollments, { + as: 'class_enrollments_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.class_subjects, { + as: 'class_subjects_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.timetables, { + as: 'timetables_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.timetable_periods, { + as: 'timetable_periods_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.attendance_sessions, { + as: 'attendance_sessions_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.attendance_records, { + as: 'attendance_records_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.fee_plans, { + as: 'fee_plans_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.invoices, { + as: 'invoices_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.payments, { + as: 'payments_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.assessments, { + as: 'assessments_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.assessment_results, { + as: 'assessment_results_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.messages, { + as: 'messages_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.message_recipients, { + as: 'message_recipients_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + db.organizations.hasMany(db.documents, { + as: 'documents_organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + +//end loop + + + + + + + db.organizations.belongsTo(db.users, { + as: 'createdBy', + }); + + db.organizations.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Organizations { + Organizations.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'organizations', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Organizations; +} diff --git a/backend/src/db/models/payments.js b/backend/src/db/models/payments.js deleted file mode 100644 index 78d8225..0000000 --- a/backend/src/db/models/payments.js +++ /dev/null @@ -1,181 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const payments = sequelize.define( - 'payments', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -receipt_number: { - type: DataTypes.TEXT, - - - - }, - -paid_at: { - type: DataTypes.DATE, - - - - }, - -amount: { - type: DataTypes.DECIMAL, - - - - }, - -method: { - type: DataTypes.ENUM, - - - - values: [ - -"cash", - - -"bank_transfer", - - -"card", - - -"mobile_money", - - -"cheque", - - -"other" - - ], - - }, - -reference_code: { - type: DataTypes.TEXT, - - - - }, - -notes: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - payments.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.payments.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.payments.belongsTo(db.invoices, { - as: 'invoice', - foreignKey: { - name: 'invoiceId', - }, - constraints: false, - }); - - db.payments.belongsTo(db.staff, { - as: 'received_by', - foreignKey: { - name: 'received_byId', - }, - constraints: false, - }); - - - - db.payments.hasMany(db.file, { - as: 'proof', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.payments.getTableName(), - belongsToColumn: 'proof', - }, - }); - - - db.payments.belongsTo(db.users, { - as: 'createdBy', - }); - - db.payments.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return payments; -}; - - diff --git a/backend/src/db/models/payments.ts b/backend/src/db/models/payments.ts new file mode 100644 index 0000000..ef7114d --- /dev/null +++ b/backend/src/db/models/payments.ts @@ -0,0 +1,237 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { File } from './file'; +import type { Invoices } from './invoices'; +import type { Organizations } from './organizations'; +import type { Staff } from './staff'; +import type { Users } from './users'; + +export class Payments extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare receipt_number: string | null; + declare paid_at: Date | null; + declare amount: string | null; + declare method: string | null; + declare reference_code: string | null; + declare notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare invoiceId: CreationOptional; + declare organizationId: CreationOptional; + declare received_byId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getInvoice: BelongsToGetAssociationMixin; + declare setInvoice: BelongsToSetAssociationMixin; + declare getReceived_by: BelongsToGetAssociationMixin; + declare setReceived_by: BelongsToSetAssociationMixin; + declare getProof: HasManyGetAssociationsMixin; + declare setProof: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.payments.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.payments.belongsTo(db.invoices, { + as: 'invoice', + foreignKey: { + name: 'invoiceId', + }, + constraints: false, + }); + + db.payments.belongsTo(db.staff, { + as: 'received_by', + foreignKey: { + name: 'received_byId', + }, + constraints: false, + }); + + + + db.payments.hasMany(db.file, { + as: 'proof', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.payments.getTableName(), + belongsToColumn: 'proof', + }, + }); + + + db.payments.belongsTo(db.users, { + as: 'createdBy', + }); + + db.payments.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Payments { + Payments.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +receipt_number: { + type: DataTypes.TEXT, + + + + }, + +paid_at: { + type: DataTypes.DATE, + + + + }, + +amount: { + type: DataTypes.DECIMAL, + + + + }, + +method: { + type: DataTypes.ENUM, + + + + values: [ + +"cash", + + +"bank_transfer", + + +"card", + + +"mobile_money", + + +"cheque", + + +"other" + + ], + + }, + +reference_code: { + type: DataTypes.TEXT, + + + + }, + +notes: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + invoiceId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + received_byId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'payments', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Payments; +} diff --git a/backend/src/db/models/permissions.js b/backend/src/db/models/permissions.js deleted file mode 100644 index c59f4b0..0000000 --- a/backend/src/db/models/permissions.js +++ /dev/null @@ -1,91 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const permissions = sequelize.define( - 'permissions', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - permissions.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - - - - db.permissions.belongsTo(db.users, { - as: 'createdBy', - }); - - db.permissions.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return permissions; -}; - - diff --git a/backend/src/db/models/permissions.ts b/backend/src/db/models/permissions.ts new file mode 100644 index 0000000..cd5fdc2 --- /dev/null +++ b/backend/src/db/models/permissions.ts @@ -0,0 +1,122 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Users } from './users'; + +export class Permissions extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + + + + db.permissions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.permissions.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Permissions { + Permissions.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'permissions', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Permissions; +} diff --git a/backend/src/db/models/personality_quiz_results.js b/backend/src/db/models/personality_quiz_results.js deleted file mode 100644 index 8dc1abc..0000000 --- a/backend/src/db/models/personality_quiz_results.js +++ /dev/null @@ -1,70 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const personality_quiz_results = sequelize.define( - 'personality_quiz_results', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - personality_type: { - type: DataTypes.TEXT, - allowNull: false, - }, - quiz_answers: { - type: DataTypes.JSONB, - allowNull: false, - }, - completed_at: { - type: DataTypes.DATE, - allowNull: false, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - personality_quiz_results.associate = (db) => { - db.personality_quiz_results.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.personality_quiz_results.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.personality_quiz_results.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - db.personality_quiz_results.belongsTo(db.users, { - as: 'createdBy', - }); - - db.personality_quiz_results.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return personality_quiz_results; -}; diff --git a/backend/src/db/models/personality_quiz_results.ts b/backend/src/db/models/personality_quiz_results.ts new file mode 100644 index 0000000..aee367a --- /dev/null +++ b/backend/src/db/models/personality_quiz_results.ts @@ -0,0 +1,127 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class PersonalityQuizResults extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare personality_type: string; + declare quiz_answers: unknown; + declare completed_at: Date; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare userId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.personality_quiz_results.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'createdBy', + }); + + db.personality_quiz_results.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof PersonalityQuizResults { + PersonalityQuizResults.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + personality_type: { + type: DataTypes.TEXT, + allowNull: false, + }, + quiz_answers: { + type: DataTypes.JSONB, + allowNull: false, + }, + completed_at: { + type: DataTypes.DATE, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + userId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'personality_quiz_results', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return PersonalityQuizResults; +} diff --git a/backend/src/db/models/roles.js b/backend/src/db/models/roles.js deleted file mode 100644 index b8b7ac0..0000000 --- a/backend/src/db/models/roles.js +++ /dev/null @@ -1,134 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const roles = sequelize.define( - 'roles', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -role_customization: { - type: DataTypes.TEXT, - - - - }, - -globalAccess: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - roles.associate = (db) => { - - db.roles.belongsToMany(db.permissions, { - as: 'permissions', - foreignKey: { - name: 'roles_permissionsId', - }, - constraints: false, - through: 'rolesPermissionsPermissions', - }); - - db.roles.belongsToMany(db.permissions, { - as: 'permissions_filter', - foreignKey: { - name: 'roles_permissionsId', - }, - constraints: false, - through: 'rolesPermissionsPermissions', - }); - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - db.roles.hasMany(db.users, { - as: 'users_app_role', - foreignKey: { - name: 'app_roleId', - }, - constraints: false, - }); - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - - - - db.roles.belongsTo(db.users, { - as: 'createdBy', - }); - - db.roles.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return roles; -}; - - diff --git a/backend/src/db/models/roles.ts b/backend/src/db/models/roles.ts new file mode 100644 index 0000000..72ca52a --- /dev/null +++ b/backend/src/db/models/roles.ts @@ -0,0 +1,173 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type NonAttribute, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToManyGetAssociationsMixin, + BelongsToManySetAssociationsMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { Permissions } from './permissions'; +import type { Users } from './users'; + +export class Roles extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare globalAccess: CreationOptional; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + // Eager-loaded association (populated by `include`). + declare permissions?: NonAttribute; + + declare getPermissions: BelongsToManyGetAssociationsMixin; + declare setPermissions: BelongsToManySetAssociationsMixin; + declare getPermissions_filter: BelongsToManyGetAssociationsMixin; + declare setPermissions_filter: BelongsToManySetAssociationsMixin; + declare getUsers_app_role: HasManyGetAssociationsMixin; + declare setUsers_app_role: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + db.roles.belongsToMany(db.permissions, { + as: 'permissions', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + db.roles.belongsToMany(db.permissions, { + as: 'permissions_filter', + foreignKey: { + name: 'roles_permissionsId', + }, + constraints: false, + through: 'rolesPermissionsPermissions', + }); + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + db.roles.hasMany(db.users, { + as: 'users_app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + + + + db.roles.belongsTo(db.users, { + as: 'createdBy', + }); + + db.roles.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Roles { + Roles.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +globalAccess: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'roles', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Roles; +} diff --git a/backend/src/db/models/safety_quiz_results.js b/backend/src/db/models/safety_quiz_results.js deleted file mode 100644 index 54d4dcc..0000000 --- a/backend/src/db/models/safety_quiz_results.js +++ /dev/null @@ -1,94 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const safety_quiz_results = sequelize.define( - 'safety_quiz_results', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - quiz_id: { - type: DataTypes.TEXT, - allowNull: false, - }, - quiz_title: { - type: DataTypes.TEXT, - allowNull: false, - }, - week_of: { - type: DataTypes.TEXT, - allowNull: false, - }, - score: { - type: DataTypes.INTEGER, - allowNull: false, - }, - total_questions: { - type: DataTypes.INTEGER, - allowNull: false, - }, - answers: { - type: DataTypes.JSONB, - allowNull: false, - }, - user_name: { - type: DataTypes.TEXT, - allowNull: false, - }, - user_role: { - type: DataTypes.TEXT, - allowNull: false, - }, - completed_at: { - type: DataTypes.DATE, - allowNull: false, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - safety_quiz_results.associate = (db) => { - safety_quiz_results.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - safety_quiz_results.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - safety_quiz_results.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - safety_quiz_results.belongsTo(db.users, { - as: 'createdBy', - }); - - safety_quiz_results.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return safety_quiz_results; -}; diff --git a/backend/src/db/models/safety_quiz_results.ts b/backend/src/db/models/safety_quiz_results.ts new file mode 100644 index 0000000..2dc028a --- /dev/null +++ b/backend/src/db/models/safety_quiz_results.ts @@ -0,0 +1,157 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class SafetyQuizResults extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare quiz_id: string; + declare quiz_title: string; + declare week_of: string; + declare score: number; + declare total_questions: number; + declare answers: unknown; + declare user_name: string; + declare user_role: string; + declare completed_at: Date; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare userId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.safety_quiz_results.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.safety_quiz_results.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.safety_quiz_results.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.safety_quiz_results.belongsTo(db.users, { + as: 'createdBy', + }); + + db.safety_quiz_results.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof SafetyQuizResults { + SafetyQuizResults.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + quiz_id: { + type: DataTypes.TEXT, + allowNull: false, + }, + quiz_title: { + type: DataTypes.TEXT, + allowNull: false, + }, + week_of: { + type: DataTypes.TEXT, + allowNull: false, + }, + score: { + type: DataTypes.INTEGER, + allowNull: false, + }, + total_questions: { + type: DataTypes.INTEGER, + allowNull: false, + }, + answers: { + type: DataTypes.JSONB, + allowNull: false, + }, + user_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: DataTypes.TEXT, + allowNull: false, + }, + completed_at: { + type: DataTypes.DATE, + allowNull: false, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + userId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'safety_quiz_results', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return SafetyQuizResults; +} diff --git a/backend/src/db/models/staff.js b/backend/src/db/models/staff.js deleted file mode 100644 index be1d48b..0000000 --- a/backend/src/db/models/staff.js +++ /dev/null @@ -1,209 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const staff = sequelize.define( - 'staff', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -employee_number: { - type: DataTypes.TEXT, - - - - }, - -job_title: { - type: DataTypes.TEXT, - - - - }, - -staff_type: { - type: DataTypes.ENUM, - - - - values: [ - -"teacher", - - -"admin", - - -"support" - - ], - - }, - -hire_date: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"active", - - -"on_leave", - - -"inactive" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - staff.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - db.staff.hasMany(db.classes, { - as: 'classes_homeroom_teacher', - foreignKey: { - name: 'homeroom_teacherId', - }, - constraints: false, - }); - - - - db.staff.hasMany(db.class_subjects, { - as: 'class_subjects_teacher', - foreignKey: { - name: 'teacherId', - }, - constraints: false, - }); - - - - - db.staff.hasMany(db.attendance_sessions, { - as: 'attendance_sessions_taken_by', - foreignKey: { - name: 'taken_byId', - }, - constraints: false, - }); - - - - - - db.staff.hasMany(db.payments, { - as: 'payments_received_by', - foreignKey: { - name: 'received_byId', - }, - constraints: false, - }); - - - - - - - - -//end loop - - - - db.staff.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.staff.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.staff.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - - - db.staff.hasMany(db.file, { - as: 'photo', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.staff.getTableName(), - belongsToColumn: 'photo', - }, - }); - - - db.staff.belongsTo(db.users, { - as: 'createdBy', - }); - - db.staff.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return staff; -}; - - diff --git a/backend/src/db/models/staff.ts b/backend/src/db/models/staff.ts new file mode 100644 index 0000000..3c41d43 --- /dev/null +++ b/backend/src/db/models/staff.ts @@ -0,0 +1,278 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type NonAttribute, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AttendanceSessions } from './attendance_sessions'; +import type { Campuses } from './campuses'; +import type { ClassSubjects } from './class_subjects'; +import type { Classes } from './classes'; +import type { File } from './file'; +import type { Organizations } from './organizations'; +import type { Payments } from './payments'; +import type { Users } from './users'; + +export class Staff extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare employee_number: string | null; + declare job_title: string | null; + declare staff_type: string | null; + declare hire_date: Date | null; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare userId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getClasses_homeroom_teacher: HasManyGetAssociationsMixin; + declare setClasses_homeroom_teacher: HasManySetAssociationsMixin; + declare getClass_subjects_teacher: HasManyGetAssociationsMixin; + declare setClass_subjects_teacher: HasManySetAssociationsMixin; + declare getAttendance_sessions_taken_by: HasManyGetAssociationsMixin; + declare setAttendance_sessions_taken_by: HasManySetAssociationsMixin; + declare getPayments_received_by: HasManyGetAssociationsMixin; + declare setPayments_received_by: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + // Eager-loaded association (populated by `include`). + declare campus?: NonAttribute; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getPhoto: HasManyGetAssociationsMixin; + declare setPhoto: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + db.staff.hasMany(db.classes, { + as: 'classes_homeroom_teacher', + foreignKey: { + name: 'homeroom_teacherId', + }, + constraints: false, + }); + + + + db.staff.hasMany(db.class_subjects, { + as: 'class_subjects_teacher', + foreignKey: { + name: 'teacherId', + }, + constraints: false, + }); + + + + + db.staff.hasMany(db.attendance_sessions, { + as: 'attendance_sessions_taken_by', + foreignKey: { + name: 'taken_byId', + }, + constraints: false, + }); + + + + + + db.staff.hasMany(db.payments, { + as: 'payments_received_by', + foreignKey: { + name: 'received_byId', + }, + constraints: false, + }); + + + + + + + + +//end loop + + + + db.staff.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.staff.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.staff.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + + + db.staff.hasMany(db.file, { + as: 'photo', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.staff.getTableName(), + belongsToColumn: 'photo', + }, + }); + + + db.staff.belongsTo(db.users, { + as: 'createdBy', + }); + + db.staff.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Staff { + Staff.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +employee_number: { + type: DataTypes.TEXT, + + + + }, + +job_title: { + type: DataTypes.TEXT, + + + + }, + +staff_type: { + type: DataTypes.ENUM, + + + + values: [ + +"teacher", + + +"admin", + + +"support" + + ], + + }, + +hire_date: { + type: DataTypes.DATE, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"active", + + +"on_leave", + + +"inactive" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + userId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'staff', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Staff; +} diff --git a/backend/src/db/models/staff_attendance_records.js b/backend/src/db/models/staff_attendance_records.js deleted file mode 100644 index 818b2c5..0000000 --- a/backend/src/db/models/staff_attendance_records.js +++ /dev/null @@ -1,81 +0,0 @@ -const { STAFF_ATTENDANCE_STATUSES } = require('../../constants/staff-attendance'); - -module.exports = function(sequelize, DataTypes) { - const staff_attendance_records = sequelize.define( - 'staff_attendance_records', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - attendance_date: { - type: DataTypes.DATEONLY, - allowNull: false, - }, - status: { - type: DataTypes.ENUM, - values: Object.values(STAFF_ATTENDANCE_STATUSES), - allowNull: false, - }, - note: { - type: DataTypes.TEXT, - allowNull: true, - }, - user_name: { - type: DataTypes.TEXT, - allowNull: false, - }, - user_role: { - type: DataTypes.TEXT, - allowNull: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - staff_attendance_records.associate = (db) => { - db.staff_attendance_records.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.staff_attendance_records.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.staff_attendance_records.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - db.staff_attendance_records.belongsTo(db.users, { - as: 'createdBy', - }); - - db.staff_attendance_records.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return staff_attendance_records; -}; diff --git a/backend/src/db/models/staff_attendance_records.ts b/backend/src/db/models/staff_attendance_records.ts new file mode 100644 index 0000000..a643323 --- /dev/null +++ b/backend/src/db/models/staff_attendance_records.ts @@ -0,0 +1,139 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; +import { STAFF_ATTENDANCE_STATUSES } from '@/shared/constants/staff-attendance'; + +export class StaffAttendanceRecords extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare attendance_date: string; + declare status: string; + declare note: string | null; + declare user_name: string; + declare user_role: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare userId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.staff_attendance_records.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'createdBy', + }); + + db.staff_attendance_records.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof StaffAttendanceRecords { + StaffAttendanceRecords.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + attendance_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + status: { + type: DataTypes.ENUM, + values: Object.values(STAFF_ATTENDANCE_STATUSES), + allowNull: false, + }, + note: { + type: DataTypes.TEXT, + allowNull: true, + }, + user_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + user_role: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + userId: { type: DataTypes.UUID, allowNull: false }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'staff_attendance_records', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return StaffAttendanceRecords; +} diff --git a/backend/src/db/models/students.js b/backend/src/db/models/students.js deleted file mode 100644 index 351173b..0000000 --- a/backend/src/db/models/students.js +++ /dev/null @@ -1,253 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const students = sequelize.define( - 'students', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -student_number: { - type: DataTypes.TEXT, - - - - }, - -first_name: { - type: DataTypes.TEXT, - - - - }, - -last_name: { - type: DataTypes.TEXT, - - - - }, - -gender: { - type: DataTypes.ENUM, - - - - values: [ - -"male", - - -"female", - - -"other", - - -"prefer_not_to_say" - - ], - - }, - -date_of_birth: { - type: DataTypes.DATE, - - - - }, - -enrollment_date: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"prospect", - - -"enrolled", - - -"inactive", - - -"graduated", - - -"transferred" - - ], - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -phone: { - type: DataTypes.TEXT, - - - - }, - -address: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - students.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - db.students.hasMany(db.guardians, { - as: 'guardians_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.students.hasMany(db.class_enrollments, { - as: 'class_enrollments_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - - - db.students.hasMany(db.attendance_records, { - as: 'attendance_records_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - db.students.hasMany(db.invoices, { - as: 'invoices_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - db.students.hasMany(db.assessment_results, { - as: 'assessment_results_student', - foreignKey: { - name: 'studentId', - }, - constraints: false, - }); - - - - - - -//end loop - - - - db.students.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.students.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - - - db.students.hasMany(db.file, { - as: 'photo', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.students.getTableName(), - belongsToColumn: 'photo', - }, - }); - - - db.students.belongsTo(db.users, { - as: 'createdBy', - }); - - db.students.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return students; -}; - - diff --git a/backend/src/db/models/students.ts b/backend/src/db/models/students.ts new file mode 100644 index 0000000..3420486 --- /dev/null +++ b/backend/src/db/models/students.ts @@ -0,0 +1,323 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AssessmentResults } from './assessment_results'; +import type { AttendanceRecords } from './attendance_records'; +import type { Campuses } from './campuses'; +import type { ClassEnrollments } from './class_enrollments'; +import type { File } from './file'; +import type { Guardians } from './guardians'; +import type { Invoices } from './invoices'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Students extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare student_number: string | null; + declare first_name: string | null; + declare last_name: string | null; + declare gender: string | null; + declare date_of_birth: Date | null; + declare enrollment_date: Date | null; + declare status: string | null; + declare email: string | null; + declare phone: string | null; + declare address: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getGuardians_student: HasManyGetAssociationsMixin; + declare setGuardians_student: HasManySetAssociationsMixin; + declare getClass_enrollments_student: HasManyGetAssociationsMixin; + declare setClass_enrollments_student: HasManySetAssociationsMixin; + declare getAttendance_records_student: HasManyGetAssociationsMixin; + declare setAttendance_records_student: HasManySetAssociationsMixin; + declare getInvoices_student: HasManyGetAssociationsMixin; + declare setInvoices_student: HasManySetAssociationsMixin; + declare getAssessment_results_student: HasManyGetAssociationsMixin; + declare setAssessment_results_student: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getPhoto: HasManyGetAssociationsMixin; + declare setPhoto: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + db.students.hasMany(db.guardians, { + as: 'guardians_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.students.hasMany(db.class_enrollments, { + as: 'class_enrollments_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + + + db.students.hasMany(db.attendance_records, { + as: 'attendance_records_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + db.students.hasMany(db.invoices, { + as: 'invoices_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + db.students.hasMany(db.assessment_results, { + as: 'assessment_results_student', + foreignKey: { + name: 'studentId', + }, + constraints: false, + }); + + + + + + +//end loop + + + + db.students.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.students.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + + + db.students.hasMany(db.file, { + as: 'photo', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.students.getTableName(), + belongsToColumn: 'photo', + }, + }); + + + db.students.belongsTo(db.users, { + as: 'createdBy', + }); + + db.students.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Students { + Students.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +student_number: { + type: DataTypes.TEXT, + + + + }, + +first_name: { + type: DataTypes.TEXT, + + + + }, + +last_name: { + type: DataTypes.TEXT, + + + + }, + +gender: { + type: DataTypes.ENUM, + + + + values: [ + +"male", + + +"female", + + +"other", + + +"prefer_not_to_say" + + ], + + }, + +date_of_birth: { + type: DataTypes.DATE, + + + + }, + +enrollment_date: { + type: DataTypes.DATE, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"prospect", + + +"enrolled", + + +"inactive", + + +"graduated", + + +"transferred" + + ], + + }, + +email: { + type: DataTypes.TEXT, + + + + }, + +phone: { + type: DataTypes.TEXT, + + + + }, + +address: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'students', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Students; +} diff --git a/backend/src/db/models/subjects.js b/backend/src/db/models/subjects.js deleted file mode 100644 index 55dd957..0000000 --- a/backend/src/db/models/subjects.js +++ /dev/null @@ -1,121 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const subjects = sequelize.define( - 'subjects', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -code: { - type: DataTypes.TEXT, - - - - }, - -description: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - subjects.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - db.subjects.hasMany(db.class_subjects, { - as: 'class_subjects_subject', - foreignKey: { - name: 'subjectId', - }, - constraints: false, - }); - - - - - - - - - - - - - - - -//end loop - - - - db.subjects.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - - - - db.subjects.belongsTo(db.users, { - as: 'createdBy', - }); - - db.subjects.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return subjects; -}; - - diff --git a/backend/src/db/models/subjects.ts b/backend/src/db/models/subjects.ts new file mode 100644 index 0000000..94964fc --- /dev/null +++ b/backend/src/db/models/subjects.ts @@ -0,0 +1,164 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { ClassSubjects } from './class_subjects'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class Subjects extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare code: string | null; + declare description: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getClass_subjects_subject: HasManyGetAssociationsMixin; + declare setClass_subjects_subject: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + db.subjects.hasMany(db.class_subjects, { + as: 'class_subjects_subject', + foreignKey: { + name: 'subjectId', + }, + constraints: false, + }); + + + + + + + + + + + + + + + +//end loop + + + + db.subjects.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + + + + db.subjects.belongsTo(db.users, { + as: 'createdBy', + }); + + db.subjects.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Subjects { + Subjects.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +code: { + type: DataTypes.TEXT, + + + + }, + +description: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'subjects', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Subjects; +} diff --git a/backend/src/db/models/timetable_periods.js b/backend/src/db/models/timetable_periods.js deleted file mode 100644 index 2a6c2b5..0000000 --- a/backend/src/db/models/timetable_periods.js +++ /dev/null @@ -1,160 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const timetable_periods = sequelize.define( - 'timetable_periods', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -day_of_week: { - type: DataTypes.ENUM, - - - - values: [ - -"monday", - - -"tuesday", - - -"wednesday", - - -"thursday", - - -"friday", - - -"saturday", - - -"sunday" - - ], - - }, - -starts_at: { - type: DataTypes.DATE, - - - - }, - -ends_at: { - type: DataTypes.DATE, - - - - }, - -room: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - timetable_periods.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -//end loop - - - - db.timetable_periods.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.timetable_periods.belongsTo(db.timetables, { - as: 'timetable', - foreignKey: { - name: 'timetableId', - }, - constraints: false, - }); - - db.timetable_periods.belongsTo(db.class_subjects, { - as: 'class_subject', - foreignKey: { - name: 'class_subjectId', - }, - constraints: false, - }); - - - - - db.timetable_periods.belongsTo(db.users, { - as: 'createdBy', - }); - - db.timetable_periods.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return timetable_periods; -}; - - diff --git a/backend/src/db/models/timetable_periods.ts b/backend/src/db/models/timetable_periods.ts new file mode 100644 index 0000000..a71fc7c --- /dev/null +++ b/backend/src/db/models/timetable_periods.ts @@ -0,0 +1,209 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { ClassSubjects } from './class_subjects'; +import type { Organizations } from './organizations'; +import type { Timetables } from './timetables'; +import type { Users } from './users'; + +export class TimetablePeriods extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare day_of_week: string | null; + declare starts_at: Date | null; + declare ends_at: Date | null; + declare room: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare class_subjectId: CreationOptional; + declare organizationId: CreationOptional; + declare timetableId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getTimetable: BelongsToGetAssociationMixin; + declare setTimetable: BelongsToSetAssociationMixin; + declare getClass_subject: BelongsToGetAssociationMixin; + declare setClass_subject: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +//end loop + + + + db.timetable_periods.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.timetable_periods.belongsTo(db.timetables, { + as: 'timetable', + foreignKey: { + name: 'timetableId', + }, + constraints: false, + }); + + db.timetable_periods.belongsTo(db.class_subjects, { + as: 'class_subject', + foreignKey: { + name: 'class_subjectId', + }, + constraints: false, + }); + + + + + db.timetable_periods.belongsTo(db.users, { + as: 'createdBy', + }); + + db.timetable_periods.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof TimetablePeriods { + TimetablePeriods.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +day_of_week: { + type: DataTypes.ENUM, + + + + values: [ + +"monday", + + +"tuesday", + + +"wednesday", + + +"thursday", + + +"friday", + + +"saturday", + + +"sunday" + + ], + + }, + +starts_at: { + type: DataTypes.DATE, + + + + }, + +ends_at: { + type: DataTypes.DATE, + + + + }, + +room: { + type: DataTypes.TEXT, + + + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + class_subjectId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + timetableId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'timetable_periods', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return TimetablePeriods; +} diff --git a/backend/src/db/models/timetables.js b/backend/src/db/models/timetables.js deleted file mode 100644 index 8b7e130..0000000 --- a/backend/src/db/models/timetables.js +++ /dev/null @@ -1,156 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const timetables = sequelize.define( - 'timetables', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -name: { - type: DataTypes.TEXT, - - - - }, - -effective_from: { - type: DataTypes.DATE, - - - - }, - -effective_to: { - type: DataTypes.DATE, - - - - }, - -status: { - type: DataTypes.ENUM, - - - - values: [ - -"draft", - - -"active", - - -"archived" - - ], - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - timetables.associate = (db) => { - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - - - - - - db.timetables.hasMany(db.timetable_periods, { - as: 'timetable_periods_timetable', - foreignKey: { - name: 'timetableId', - }, - constraints: false, - }); - - - - - - - - - - - - - -//end loop - - - - db.timetables.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.timetables.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.timetables.belongsTo(db.academic_years, { - as: 'academic_year', - foreignKey: { - name: 'academic_yearId', - }, - constraints: false, - }); - - - - - db.timetables.belongsTo(db.users, { - as: 'createdBy', - }); - - db.timetables.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - - return timetables; -}; - - diff --git a/backend/src/db/models/timetables.ts b/backend/src/db/models/timetables.ts new file mode 100644 index 0000000..c093f84 --- /dev/null +++ b/backend/src/db/models/timetables.ts @@ -0,0 +1,210 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { AcademicYears } from './academic_years'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { TimetablePeriods } from './timetable_periods'; +import type { Users } from './users'; + +export class Timetables extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare name: string | null; + declare effective_from: Date | null; + declare effective_to: Date | null; + declare status: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare academic_yearId: CreationOptional; + declare campusId: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getTimetable_periods_timetable: HasManyGetAssociationsMixin; + declare setTimetable_periods_timetable: HasManySetAssociationsMixin; + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getAcademic_year: BelongsToGetAssociationMixin; + declare setAcademic_year: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + + +/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + + + + + + + + + + + + + + + + + db.timetables.hasMany(db.timetable_periods, { + as: 'timetable_periods_timetable', + foreignKey: { + name: 'timetableId', + }, + constraints: false, + }); + + + + + + + + + + + + + +//end loop + + + + db.timetables.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.timetables.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.timetables.belongsTo(db.academic_years, { + as: 'academic_year', + foreignKey: { + name: 'academic_yearId', + }, + constraints: false, + }); + + + + + db.timetables.belongsTo(db.users, { + as: 'createdBy', + }); + + db.timetables.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof Timetables { + Timetables.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +name: { + type: DataTypes.TEXT, + + + + }, + +effective_from: { + type: DataTypes.DATE, + + + + }, + +effective_to: { + type: DataTypes.DATE, + + + + }, + +status: { + type: DataTypes.ENUM, + + + + values: [ + +"draft", + + +"active", + + +"archived" + + ], + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + academic_yearId: { type: DataTypes.UUID, allowNull: true }, + campusId: { type: DataTypes.UUID, allowNull: true }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'timetables', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return Timetables; +} diff --git a/backend/src/db/models/user_progress.js b/backend/src/db/models/user_progress.js deleted file mode 100644 index c7b91f7..0000000 --- a/backend/src/db/models/user_progress.js +++ /dev/null @@ -1,78 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const user_progress = sequelize.define( - 'user_progress', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - progress_type: { - type: DataTypes.TEXT, - allowNull: false, - }, - item_id: { - type: DataTypes.TEXT, - allowNull: false, - }, - value: { - type: DataTypes.TEXT, - allowNull: true, - }, - score: { - type: DataTypes.INTEGER, - allowNull: true, - }, - metadata: { - type: DataTypes.JSONB, - allowNull: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - user_progress.associate = (db) => { - db.user_progress.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - db.user_progress.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - db.user_progress.belongsTo(db.users, { - as: 'user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - db.user_progress.belongsTo(db.users, { - as: 'createdBy', - }); - - db.user_progress.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return user_progress; -}; diff --git a/backend/src/db/models/user_progress.ts b/backend/src/db/models/user_progress.ts new file mode 100644 index 0000000..b189cf4 --- /dev/null +++ b/backend/src/db/models/user_progress.ts @@ -0,0 +1,141 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import { + USER_PROGRESS_TYPE_VALUES, + type UserProgressType, +} from '@/shared/constants/user-progress'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class UserProgress extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare progress_type: UserProgressType; + declare item_id: string; + declare value: string | null; + declare score: number | null; + declare metadata: unknown | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare userId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getUser: BelongsToGetAssociationMixin; + declare setUser: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.user_progress.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.users, { + as: 'user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.user_progress.belongsTo(db.users, { + as: 'createdBy', + }); + + db.user_progress.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof UserProgress { + UserProgress.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + progress_type: { + type: DataTypes.ENUM(...USER_PROGRESS_TYPE_VALUES), + allowNull: false, + }, + item_id: { + type: DataTypes.TEXT, + allowNull: false, + }, + value: { + type: DataTypes.TEXT, + allowNull: true, + }, + score: { + type: DataTypes.INTEGER, + allowNull: true, + }, + metadata: { + type: DataTypes.JSONB, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + userId: { type: DataTypes.UUID, allowNull: false }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'user_progress', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return UserProgress; +} diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js deleted file mode 100644 index e443168..0000000 --- a/backend/src/db/models/users.js +++ /dev/null @@ -1,273 +0,0 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); - -module.exports = function(sequelize, DataTypes) { - const users = sequelize.define( - 'users', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - -firstName: { - type: DataTypes.TEXT, - - - - }, - -lastName: { - type: DataTypes.TEXT, - - - - }, - -phoneNumber: { - type: DataTypes.TEXT, - - - - }, - -email: { - type: DataTypes.TEXT, - - - - }, - -disabled: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -password: { - type: DataTypes.TEXT, - - - - }, - -emailVerified: { - type: DataTypes.BOOLEAN, - - allowNull: false, - defaultValue: false, - - - - }, - -emailVerificationToken: { - type: DataTypes.TEXT, - - - - }, - -emailVerificationTokenExpiresAt: { - type: DataTypes.DATE, - - - - }, - -passwordResetToken: { - type: DataTypes.TEXT, - - - - }, - -passwordResetTokenExpiresAt: { - type: DataTypes.DATE, - - - - }, - -provider: { - type: DataTypes.TEXT, - - - - }, - - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - users.associate = (db) => { - - db.users.belongsToMany(db.permissions, { - as: 'custom_permissions', - foreignKey: { - name: 'users_custom_permissionsId', - }, - constraints: false, - through: 'usersCustom_permissionsPermissions', - }); - - db.users.belongsToMany(db.permissions, { - as: 'custom_permissions_filter', - foreignKey: { - name: 'users_custom_permissionsId', - }, - constraints: false, - through: 'usersCustom_permissionsPermissions', - }); - - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - - - - - - db.users.hasMany(db.staff, { - as: 'staff_user', - foreignKey: { - name: 'userId', - }, - constraints: false, - }); - - - - - - - - - - - - - - - db.users.hasMany(db.messages, { - as: 'messages_sent_by', - foreignKey: { - name: 'sent_byId', - }, - constraints: false, - }); - - - - - -//end loop - - - - db.users.belongsTo(db.roles, { - as: 'app_role', - foreignKey: { - name: 'app_roleId', - }, - constraints: false, - }); - - db.users.belongsTo(db.organizations, { - as: 'organizations', - foreignKey: { - name: 'organizationsId', - }, - constraints: false, - }); - - - - db.users.hasMany(db.file, { - as: 'avatar', - foreignKey: 'belongsToId', - constraints: false, - scope: { - belongsTo: db.users.getTableName(), - belongsToColumn: 'avatar', - }, - }); - - - db.users.belongsTo(db.users, { - as: 'createdBy', - }); - - db.users.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - - users.beforeCreate((users, options) => { - users = trimStringFields(users); - - if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) { - users.emailVerified = true; - - if (!users.password) { - const password = crypto - .randomBytes(20) - .toString('hex'); - - const hashedPassword = bcrypt.hashSync( - password, - config.bcrypt.saltRounds, - ); - - users.password = hashedPassword - } - } - }); - - users.beforeUpdate((users, options) => { - users = trimStringFields(users); - }); - - - return users; -}; - - -function trimStringFields(users) { - users.email = users.email.trim(); - - users.firstName = users.firstName - ? users.firstName.trim() - : null; - - users.lastName = users.lastName - ? users.lastName.trim() - : null; - - return users; -} - diff --git a/backend/src/db/models/users.ts b/backend/src/db/models/users.ts new file mode 100644 index 0000000..49c598c --- /dev/null +++ b/backend/src/db/models/users.ts @@ -0,0 +1,257 @@ +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type NonAttribute, + type Sequelize, +} from 'sequelize'; +import config from '@/shared/config'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, + BelongsToManyGetAssociationsMixin, + BelongsToManySetAssociationsMixin, + HasManyGetAssociationsMixin, + HasManySetAssociationsMixin, +} from 'sequelize'; +import type { File } from './file'; +import type { Messages } from './messages'; +import type { Organizations } from './organizations'; +import type { Permissions } from './permissions'; +import type { Roles } from './roles'; +import type { Staff } from './staff'; + +const providers = config.providers; + +export class Users extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare firstName: CreationOptional; + declare lastName: CreationOptional; + declare phoneNumber: CreationOptional; + declare email: string; + declare disabled: CreationOptional; + declare password: CreationOptional; + declare emailVerified: CreationOptional; + declare emailVerificationToken: CreationOptional; + declare emailVerificationTokenExpiresAt: CreationOptional; + declare passwordResetToken: CreationOptional; + declare passwordResetTokenExpiresAt: CreationOptional; + declare provider: CreationOptional; + declare importHash: CreationOptional; + declare organizationId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + + // Eager-loaded associations (populated by `include`, not stored attributes). + declare app_role?: NonAttribute; + declare organizations?: NonAttribute; + declare staff_user?: NonAttribute; + declare custom_permissions?: NonAttribute; + + declare getCustom_permissions: BelongsToManyGetAssociationsMixin; + declare setCustom_permissions: BelongsToManySetAssociationsMixin; + declare getCustom_permissions_filter: BelongsToManyGetAssociationsMixin; + declare setCustom_permissions_filter: BelongsToManySetAssociationsMixin; + declare getStaff_user: HasManyGetAssociationsMixin; + declare setStaff_user: HasManySetAssociationsMixin; + declare getMessages_sent_by: HasManyGetAssociationsMixin; + declare setMessages_sent_by: HasManySetAssociationsMixin; + declare getApp_role: BelongsToGetAssociationMixin; + declare setApp_role: BelongsToSetAssociationMixin; + declare getOrganizations: BelongsToGetAssociationMixin; + declare setOrganizations: BelongsToSetAssociationMixin; + declare getAvatar: HasManyGetAssociationsMixin; + declare setAvatar: HasManySetAssociationsMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + db.users.belongsToMany(db.permissions, { + as: 'custom_permissions_filter', + foreignKey: { + name: 'users_custom_permissionsId', + }, + constraints: false, + through: 'usersCustom_permissionsPermissions', + }); + + db.users.hasMany(db.staff, { + as: 'staff_user', + foreignKey: { + name: 'userId', + }, + constraints: false, + }); + + db.users.hasMany(db.messages, { + as: 'messages_sent_by', + foreignKey: { + name: 'sent_byId', + }, + constraints: false, + }); + + db.users.belongsTo(db.roles, { + as: 'app_role', + foreignKey: { + name: 'app_roleId', + }, + constraints: false, + }); + + db.users.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.users.hasMany(db.file, { + as: 'avatar', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: db.users.getTableName(), + belongsToColumn: 'avatar', + }, + }); + + db.users.belongsTo(db.users, { + as: 'createdBy', + }); + + db.users.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +function trimStringFields(user: Users): Users { + user.email = user.email.trim(); + + user.firstName = user.firstName ? user.firstName.trim() : null; + + user.lastName = user.lastName ? user.lastName.trim() : null; + + return user; +} + +export default function (sequelize: Sequelize): typeof Users { + Users.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + firstName: { + type: DataTypes.TEXT, + }, + lastName: { + type: DataTypes.TEXT, + }, + phoneNumber: { + type: DataTypes.TEXT, + }, + email: { + type: DataTypes.TEXT, + allowNull: false, + }, + disabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + password: { + type: DataTypes.TEXT, + }, + emailVerified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + emailVerificationToken: { + type: DataTypes.TEXT, + }, + emailVerificationTokenExpiresAt: { + type: DataTypes.DATE, + }, + passwordResetToken: { + type: DataTypes.TEXT, + }, + passwordResetTokenExpiresAt: { + type: DataTypes.DATE, + }, + provider: { + type: DataTypes.TEXT, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + organizationId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: true }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + }, + { + sequelize, + modelName: 'users', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + const providerValues: string[] = Object.values(providers); + + Users.beforeCreate((user: Users) => { + trimStringFields(user); + + if ( + user.provider && + user.provider !== providers.LOCAL && + providerValues.includes(user.provider) + ) { + user.emailVerified = true; + + if (!user.password) { + const password = crypto.randomBytes(20).toString('hex'); + + user.password = bcrypt.hashSync(password, config.bcrypt.saltRounds); + } + } + }); + + Users.beforeUpdate((user: Users) => { + trimStringFields(user); + }); + + return Users; +} diff --git a/backend/src/db/models/walkthrough_checkins.js b/backend/src/db/models/walkthrough_checkins.js deleted file mode 100644 index 98efed6..0000000 --- a/backend/src/db/models/walkthrough_checkins.js +++ /dev/null @@ -1,130 +0,0 @@ -module.exports = function(sequelize, DataTypes) { - const walkthrough_checkins = sequelize.define( - 'walkthrough_checkins', - { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - teacher_name: { - type: DataTypes.TEXT, - allowNull: false, - }, - classroom: { - type: DataTypes.TEXT, - allowNull: false, - }, - director_name: { - type: DataTypes.TEXT, - allowNull: false, - }, - check_in_date: { - type: DataTypes.DATEONLY, - allowNull: false, - }, - check_in_time: { - type: DataTypes.TIME, - allowNull: false, - }, - attitude_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - attitude_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - classroom_management_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - classroom_management_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - cleanliness_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - cleanliness_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - vibes_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - vibes_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - team_dynamics_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - team_dynamics_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - emergency_exit_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - emergency_exit_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - lesson_plan_rating: { - type: DataTypes.INTEGER, - allowNull: false, - }, - lesson_plan_comment: { - type: DataTypes.TEXT, - allowNull: true, - }, - overall_notes: { - type: DataTypes.TEXT, - allowNull: true, - }, - importHash: { - type: DataTypes.STRING(255), - allowNull: true, - unique: true, - }, - }, - { - timestamps: true, - paranoid: true, - freezeTableName: true, - }, - ); - - walkthrough_checkins.associate = (db) => { - walkthrough_checkins.belongsTo(db.organizations, { - as: 'organization', - foreignKey: { - name: 'organizationId', - }, - constraints: false, - }); - - walkthrough_checkins.belongsTo(db.campuses, { - as: 'campus', - foreignKey: { - name: 'campusId', - }, - constraints: false, - }); - - walkthrough_checkins.belongsTo(db.users, { - as: 'createdBy', - }); - - walkthrough_checkins.belongsTo(db.users, { - as: 'updatedBy', - }); - }; - - return walkthrough_checkins; -}; diff --git a/backend/src/db/models/walkthrough_checkins.ts b/backend/src/db/models/walkthrough_checkins.ts new file mode 100644 index 0000000..8891b18 --- /dev/null +++ b/backend/src/db/models/walkthrough_checkins.ts @@ -0,0 +1,200 @@ +import { + DataTypes, + Model, + type CreationOptional, + type InferAttributes, + type InferCreationAttributes, + type Sequelize, +} from 'sequelize'; +import type { Db } from '@/db/types'; +import type { + BelongsToGetAssociationMixin, + BelongsToSetAssociationMixin, +} from 'sequelize'; +import type { Campuses } from './campuses'; +import type { Organizations } from './organizations'; +import type { Users } from './users'; + +export class WalkthroughCheckins extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare teacher_name: string; + declare classroom: string; + declare director_name: string; + declare check_in_date: string; + declare check_in_time: string; + declare attitude_rating: number; + declare attitude_comment: string | null; + declare classroom_management_rating: number; + declare classroom_management_comment: string | null; + declare cleanliness_rating: number; + declare cleanliness_comment: string | null; + declare vibes_rating: number; + declare vibes_comment: string | null; + declare team_dynamics_rating: number; + declare team_dynamics_comment: string | null; + declare emergency_exit_rating: number; + declare emergency_exit_comment: string | null; + declare lesson_plan_rating: number; + declare lesson_plan_comment: string | null; + declare overall_notes: string | null; + declare importHash: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + declare deletedAt: CreationOptional; + declare organizationId: CreationOptional; + declare campusId: CreationOptional; + declare createdById: CreationOptional; + declare updatedById: CreationOptional; + + + declare getOrganization: BelongsToGetAssociationMixin; + declare setOrganization: BelongsToSetAssociationMixin; + declare getCampus: BelongsToGetAssociationMixin; + declare setCampus: BelongsToSetAssociationMixin; + declare getCreatedBy: BelongsToGetAssociationMixin; + declare setCreatedBy: BelongsToSetAssociationMixin; + declare getUpdatedBy: BelongsToGetAssociationMixin; + declare setUpdatedBy: BelongsToSetAssociationMixin; + + static associate(db: Db): void { + db.walkthrough_checkins.belongsTo(db.organizations, { + as: 'organization', + foreignKey: { + name: 'organizationId', + }, + constraints: false, + }); + + db.walkthrough_checkins.belongsTo(db.campuses, { + as: 'campus', + foreignKey: { + name: 'campusId', + }, + constraints: false, + }); + + db.walkthrough_checkins.belongsTo(db.users, { + as: 'createdBy', + }); + + db.walkthrough_checkins.belongsTo(db.users, { + as: 'updatedBy', + }); + } +} + +export default function (sequelize: Sequelize): typeof WalkthroughCheckins { + WalkthroughCheckins.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + teacher_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + classroom: { + type: DataTypes.TEXT, + allowNull: false, + }, + director_name: { + type: DataTypes.TEXT, + allowNull: false, + }, + check_in_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + check_in_time: { + type: DataTypes.TIME, + allowNull: false, + }, + attitude_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + attitude_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + classroom_management_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + classroom_management_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + cleanliness_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + cleanliness_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + vibes_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + vibes_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + team_dynamics_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + team_dynamics_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + emergency_exit_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + emergency_exit_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + lesson_plan_rating: { + type: DataTypes.INTEGER, + allowNull: false, + }, + lesson_plan_comment: { + type: DataTypes.TEXT, + allowNull: true, + }, + overall_notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { type: DataTypes.DATE }, + updatedAt: { type: DataTypes.DATE }, + deletedAt: { type: DataTypes.DATE }, + organizationId: { type: DataTypes.UUID, allowNull: false }, + campusId: { type: DataTypes.UUID, allowNull: true }, + createdById: { type: DataTypes.UUID, allowNull: false }, + updatedById: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + modelName: 'walkthrough_checkins', + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + return WalkthroughCheckins; +} diff --git a/backend/src/db/reset.js b/backend/src/db/reset.js deleted file mode 100644 index 5904d4b..0000000 --- a/backend/src/db/reset.js +++ /dev/null @@ -1,16 +0,0 @@ -const db = require('./models'); -const {execSync} = require("child_process"); - -console.log('Resetting Database'); - -db.sequelize - .sync({ force: true }) - .then(() => { - execSync("sequelize db:seed:all"); - console.log('OK'); - process.exit(); - }) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/backend/src/db/reset.ts b/backend/src/db/reset.ts new file mode 100644 index 0000000..807ec07 --- /dev/null +++ b/backend/src/db/reset.ts @@ -0,0 +1,27 @@ +import db from '@/db/models'; +import { migrator, seeder } from '@/db/umzug'; + +async function reset(): Promise { + console.log('Resetting database (drop all tables, migrate, seed)...'); + // Drop every table in the public schema (including the umzug tracking tables) + // so migrations and seeders re-apply from scratch. Avoids `DROP SCHEMA`, which + // requires schema ownership the app DB user may not have. + await db.sequelize.query(` + DO $$ + DECLARE r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS "' || r.tablename || '" CASCADE'; + END LOOP; + END $$; + `); + await migrator.up(); + await seeder.up(); + await db.sequelize.close(); + console.log('Done.'); +} + +reset().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/backend/src/db/seeders/20200430130759-admin-user.js b/backend/src/db/seeders/20200430130759-admin-user.js deleted file mode 100644 index 15022fe..0000000 --- a/backend/src/db/seeders/20200430130759-admin-user.js +++ /dev/null @@ -1,77 +0,0 @@ -'use strict'; -const bcrypt = require("bcrypt"); -const config = require("../../config"); - -const ids = [ - '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af', - 'af5a87be-8f9c-4630-902a-37a60b7005ba', - '5bc531ab-611f-41f3-9373-b7cc5d09c93d', - 'ab4cf9bf-4eef-4107-b73d-9d0274cf69bc', -] - -module.exports = { - up: async (queryInterface, Sequelize) => { - let admin_hash = bcrypt.hashSync(config.admin_pass, config.bcrypt.saltRounds); - let user_hash = bcrypt.hashSync(config.user_pass, config.bcrypt.saltRounds); - - try { - await queryInterface.bulkInsert('users', [ - { - id: ids[0], - firstName: 'Admin', - email: config.admin_email, - emailVerified: true, - provider: config.providers.LOCAL, - password: admin_hash, - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: ids[1], - firstName: 'John', - email: 'john@doe.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: user_hash, - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: ids[2], - firstName: 'Client', - email: 'client@hello.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: user_hash, - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: ids[3], - firstName: 'Super Admin', - email: 'super_admin@flatlogic.com', - emailVerified: true, - provider: config.providers.LOCAL, - password: admin_hash, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); - } catch (error) { - console.error('Error during bulkInsert:', error); - throw error; - } - }, - down: async (queryInterface, Sequelize) => { - try { - await queryInterface.bulkDelete('users', { - id: { - [Sequelize.Op.in]: ids, - }, - }, {}); - } catch (error) { - console.error('Error during bulkDelete:', error); - throw error; - } -} -} diff --git a/backend/src/db/seeders/20200430130759-admin-user.ts b/backend/src/db/seeders/20200430130759-admin-user.ts new file mode 100644 index 0000000..f794318 --- /dev/null +++ b/backend/src/db/seeders/20200430130759-admin-user.ts @@ -0,0 +1,85 @@ +import bcrypt from 'bcrypt'; +import { Op, type CreationAttributes, type QueryInterface } from 'sequelize'; +import config from '@/shared/config'; +import type { Users } from '@/db/models/users'; + +/** Seed-only env vars are required: the admin account is needed to log in. */ +function requiredSeedEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Seeding requires environment variable: ${name}`); + } + return value; +} + +const ids = [ + '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af', + 'af5a87be-8f9c-4630-902a-37a60b7005ba', + '5bc531ab-611f-41f3-9373-b7cc5d09c93d', + 'ab4cf9bf-4eef-4107-b73d-9d0274cf69bc', +]; + +export default { + up: async (queryInterface: QueryInterface) => { + const adminHash = bcrypt.hashSync( + requiredSeedEnv('SEED_ADMIN_PASSWORD'), + config.bcrypt.saltRounds, + ); + const userHash = bcrypt.hashSync( + requiredSeedEnv('SEED_USER_PASSWORD'), + config.bcrypt.saltRounds, + ); + const adminEmail = requiredSeedEnv('SEED_ADMIN_EMAIL'); + const createdAt = new Date(); + const updatedAt = new Date(); + + const rows: CreationAttributes[] = [ + { + id: ids[0], + firstName: 'Admin', + email: adminEmail, + emailVerified: true, + provider: config.providers.LOCAL, + password: adminHash, + createdAt, + updatedAt, + }, + { + id: ids[1], + firstName: 'John', + email: 'john@doe.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: userHash, + createdAt, + updatedAt, + }, + { + id: ids[2], + firstName: 'Client', + email: 'client@hello.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: userHash, + createdAt, + updatedAt, + }, + { + id: ids[3], + firstName: 'Super Admin', + email: 'super_admin@flatlogic.com', + emailVerified: true, + provider: config.providers.LOCAL, + password: adminHash, + createdAt, + updatedAt, + }, + ]; + + await queryInterface.bulkInsert('users', rows); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete('users', { id: { [Op.in]: ids } }, {}); + }, +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.ts similarity index 99% rename from backend/src/db/seeders/20200430130760-user-roles.js rename to backend/src/db/seeders/20200430130760-user-roles.ts index c763bf2..1db4e8f 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -1,25 +1,20 @@ -const { v4: uuid } = require("uuid"); +import { v4 as uuid } from 'uuid'; +import type { CreationAttributes, QueryInterface } from 'sequelize'; +import type { Roles } from '@/db/models/roles'; +import type { Permissions } from '@/db/models/permissions'; -module.exports = { - /** - * @param{import("sequelize").QueryInterface} queryInterface - * @return {Promise} - */ - async up(queryInterface) { +export default { + async up(queryInterface: QueryInterface) { const createdAt = new Date(); const updatedAt = new Date(); - /** @type {Map} */ - const idMap = new Map(); + const idMap = new Map(); - /** - * @param {string} key - * @return {string} - */ - function getId(key) { - if (idMap.has(key)) { - return idMap.get(key); + function getId(key: string): string { + const existing = idMap.get(key); + if (existing !== undefined) { + return existing; } const id = uuid(); idMap.set(key, id); @@ -48,12 +43,11 @@ module.exports = { { id: getId("Public"), name: "Public", createdAt, updatedAt }, - ]); + ] satisfies CreationAttributes[]); - /** - * @param {string} name - */ - function createPermissions(name) { + function createPermissions( + name: string, + ): CreationAttributes[] { return [ { id: getId(`CREATE_${name.toUpperCase()}`), createdAt, updatedAt, name: `CREATE_${name.toUpperCase()}` }, { id: getId(`READ_${name.toUpperCase()}`), createdAt, updatedAt, name: `READ_${name.toUpperCase()}` }, @@ -63,25 +57,17 @@ module.exports = { } const entities = [ - "users","roles","permissions","organizations","campuses","academic_years","grades","subjects","students","guardians","staff","classes","class_enrollments","class_subjects","timetables","timetable_periods","attendance_sessions","attendance_records","fee_plans","invoices","payments","assessments","assessment_results","messages","message_recipients","documents",, + "users","roles","permissions","organizations","campuses","academic_years","grades","subjects","students","guardians","staff","classes","class_enrollments","class_subjects","timetables","timetable_periods","attendance_sessions","attendance_records","fee_plans","invoices","payments","assessments","assessment_results","messages","message_recipients","documents", ]; await queryInterface.bulkInsert("permissions", entities.flatMap(createPermissions)); await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), createdAt, updatedAt, name: `READ_API_DOCS` }]); await queryInterface.bulkInsert("permissions", [{ id: getId(`CREATE_SEARCH`), createdAt, updatedAt, name: `CREATE_SEARCH`}]); await queryInterface.bulkUpdate('roles', { globalAccess: true }, { id: getId('SuperAdmin') }); +await queryInterface.bulkUpdate('roles', { globalAccess: true }, { id: getId('Administrator') }); - -await queryInterface.sequelize.query(`create table "rolesPermissionsPermissions" -( -"createdAt" timestamp with time zone not null, -"updatedAt" timestamp with time zone not null, -"roles_permissionsId" uuid not null, -"permissionId" uuid not null, -primary key ("roles_permissionsId", "permissionId") -);`); - - +// The "rolesPermissionsPermissions" join table is created by `sequelize.sync` +// from the roles<->permissions M:N association, so the seeder only inserts rows. await queryInterface.bulkInsert("rolesPermissionsPermissions", [ diff --git a/backend/src/db/seeders/20260608100000-product-campuses.js b/backend/src/db/seeders/20260608100000-product-campuses.js deleted file mode 100644 index 5f34705..0000000 --- a/backend/src/db/seeders/20260608100000-product-campuses.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -const { PRODUCT_CAMPUS_SEED_ROWS } = require('../../constants/campuses'); - -module.exports = { - up: async (queryInterface) => { - const createdAt = new Date(); - const updatedAt = new Date(); - - await queryInterface.bulkInsert('campuses', PRODUCT_CAMPUS_SEED_ROWS.map((campus) => ({ - ...campus, - createdAt, - updatedAt, - }))); - }, - - down: async (queryInterface, Sequelize) => { - await queryInterface.bulkDelete('campuses', { - id: { - [Sequelize.Op.in]: PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id), - }, - }); - }, -}; diff --git a/backend/src/db/seeders/20260608100000-product-campuses.ts b/backend/src/db/seeders/20260608100000-product-campuses.ts new file mode 100644 index 0000000..c7e90d2 --- /dev/null +++ b/backend/src/db/seeders/20260608100000-product-campuses.ts @@ -0,0 +1,28 @@ +import { + Op, + type CreationAttributes, + type QueryInterface, +} from 'sequelize'; +import { PRODUCT_CAMPUS_SEED_ROWS } from '@/shared/constants/campuses'; +import type { Campuses } from '@/db/models/campuses'; + +export default { + up: async (queryInterface: QueryInterface) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + const rows: CreationAttributes[] = PRODUCT_CAMPUS_SEED_ROWS.map( + (campus) => ({ ...campus, createdAt, updatedAt }), + ); + + await queryInterface.bulkInsert('campuses', rows); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.bulkDelete('campuses', { + id: { + [Op.in]: PRODUCT_CAMPUS_SEED_ROWS.map((campus) => campus.id), + }, + }); + }, +}; diff --git a/backend/src/db/seeders/20260608103000-content-catalog.js b/backend/src/db/seeders/20260608103000-content-catalog.ts similarity index 77% rename from backend/src/db/seeders/20260608103000-content-catalog.js rename to backend/src/db/seeders/20260608103000-content-catalog.ts index 61e9d07..87c8f2a 100644 --- a/backend/src/db/seeders/20260608103000-content-catalog.js +++ b/backend/src/db/seeders/20260608103000-content-catalog.ts @@ -1,6 +1,11 @@ -'use strict'; - -const { CONTENT_CATALOG_SEED_PAYLOADS } = require('./content-catalog-data/content-catalog-seed-payloads'); +import { v4 as uuid } from 'uuid'; +import { + Op, + type CreationAttributes, + type QueryInterface, +} from 'sequelize'; +import type { ContentCatalog } from '@/db/models/content_catalog'; +import { CONTENT_CATALOG_SEED_PAYLOADS } from './content-catalog-data/content-catalog-seed-payloads'; const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { content_type: 'classroom-strategies', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomStrategies }, @@ -33,30 +38,37 @@ const CONTENT_CATALOG_SEED_ROWS = Object.freeze([ { content_type: 'personality-workplace-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityWorkplaceContent }, ]); -module.exports = { - up: async (queryInterface, Sequelize) => { +export default { + up: async (queryInterface: QueryInterface) => { const now = new Date(); const contentTypes = CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type); await queryInterface.bulkDelete('content_catalog', { content_type: { - [Sequelize.Op.in]: contentTypes, + [Op.in]: contentTypes, }, }); - await queryInterface.bulkInsert('content_catalog', CONTENT_CATALOG_SEED_ROWS.map((row) => ({ - ...row, - active: true, - importHash: 'content-catalog-' + row.content_type, - createdAt: now, - updatedAt: now, - }))); + const rows: CreationAttributes[] = + CONTENT_CATALOG_SEED_ROWS.map((row) => ({ + id: uuid(), + content_type: row.content_type, + // payload is JSONB; bulkInsert needs a serialized value (Postgres casts + // the JSON text to jsonb on insert). + payload: JSON.stringify(row.payload), + active: true, + importHash: 'content-catalog-' + row.content_type, + createdAt: now, + updatedAt: now, + })); + + await queryInterface.bulkInsert('content_catalog', rows); }, - down: async (queryInterface, Sequelize) => { + down: async (queryInterface: QueryInterface) => { await queryInterface.bulkDelete('content_catalog', { content_type: { - [Sequelize.Op.in]: CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type), + [Op.in]: CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type), }, }); }, diff --git a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts similarity index 99% rename from backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js rename to backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts index b613f56..bc3da1f 100644 --- a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js +++ b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts @@ -1497,6 +1497,4 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ }, }); -module.exports = { - CONTENT_CATALOG_SEED_PAYLOADS, -}; +export { CONTENT_CATALOG_SEED_PAYLOADS }; diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts new file mode 100644 index 0000000..60479ad --- /dev/null +++ b/backend/src/db/types.ts @@ -0,0 +1,7 @@ +import type { Model, ModelStatic } from 'sequelize'; + +/** + * Registry of all models, passed to each model's `associate()`. + * Indexed by model name so association definitions can reference siblings. + */ +export type Db = Record>; diff --git a/backend/src/db/umzug.ts b/backend/src/db/umzug.ts new file mode 100644 index 0000000..347b48e --- /dev/null +++ b/backend/src/db/umzug.ts @@ -0,0 +1,107 @@ +import { Umzug, SequelizeStorage } from 'umzug'; +import { pathToFileURL } from 'url'; +import db from '@/db/models'; + +/** + * Migration/seeder runner (replaces sequelize-cli). Migration and seeder files + * are ESM TypeScript with a default export `{ up, down }` taking + * `(queryInterface, Sequelize)`. Run via `tsx` (see the `db:*` npm scripts). + */ + +type MigrationFn = (queryInterface: unknown, sequelize: unknown) => Promise; + +interface MigrationModule { + up?: MigrationFn; + down?: MigrationFn; + default?: { up?: MigrationFn; down?: MigrationFn }; +} + +const sequelize = db.sequelize; +const queryInterface = sequelize.getQueryInterface(); + +async function loadMigration( + filepath: string, +): Promise<{ up?: MigrationFn; down?: MigrationFn }> { + const mod: MigrationModule = await import(pathToFileURL(filepath).href); + return mod.default ?? mod; +} + +function resolveMigration(params: { name: string; path?: string }) { + const filepath = params.path; + if (!filepath) { + throw new Error(`Migration ${params.name} has no resolvable path`); + } + + return { + // Strip the extension so tracking is stable across tsx (.ts) and compiled + // (.js) runs. + name: params.name.replace(/\.(ts|js)$/, ''), + up: async () => { + const migration = await loadMigration(filepath); + if (migration.up) { + await migration.up(queryInterface, db.Sequelize); + } + }, + down: async () => { + const migration = await loadMigration(filepath); + if (migration.down) { + await migration.down(queryInterface, db.Sequelize); + } + }, + }; +} + +export const migrator = new Umzug({ + migrations: { + glob: ['migrations/*.{ts,js}', { cwd: import.meta.dirname }], + resolve: resolveMigration, + }, + context: queryInterface, + storage: new SequelizeStorage({ sequelize }), + logger: console, +}); + +export const seeder = new Umzug({ + migrations: { + glob: ['seeders/*.{ts,js}', { cwd: import.meta.dirname }], + resolve: resolveMigration, + }, + context: queryInterface, + storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeData' }), + logger: console, +}); + +const commands: Record Promise> = { + 'migrate:up': () => migrator.up(), + 'migrate:down': () => migrator.down(), + 'migrate:pending': async () => { + const pending = await migrator.pending(); + console.log(pending.map((m) => m.name)); + }, + 'seed:up': () => seeder.up(), + 'seed:down': () => seeder.down(), +}; + +async function runCli(): Promise { + const command = process.argv[2] ?? ''; + const handler = commands[command]; + if (!handler) { + console.error( + `Unknown command "${command}". Available: ${Object.keys(commands).join(', ')}`, + ); + process.exit(1); + } + await handler(); + await sequelize.close(); +} + +const isMain = + Boolean(process.argv[1]) && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + runCli().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/backend/src/db/utils.js b/backend/src/db/utils.js deleted file mode 100644 index c253a07..0000000 --- a/backend/src/db/utils.js +++ /dev/null @@ -1,27 +0,0 @@ -const validator = require('validator'); -const { v4: uuid } = require('uuid'); -const Sequelize = require('./models').Sequelize; - -module.exports = class Utils { - static uuid(value) { - let id = value; - - if (!validator.isUUID(id)) { - id = uuid(); - } - - return id; - } - - static ilike(model, column, value) { - return Sequelize.where( - Sequelize.fn( - 'lower', - Sequelize.col(`${model}.${column}`), - ), - { - [Sequelize.Op.like]: `%${value}%`.toLowerCase(), - }, - ); - } -}; diff --git a/backend/src/db/utils.ts b/backend/src/db/utils.ts new file mode 100644 index 0000000..b17fad8 --- /dev/null +++ b/backend/src/db/utils.ts @@ -0,0 +1,26 @@ +import validator from 'validator'; +import { v4 as uuidv4 } from 'uuid'; +import { Sequelize, Op } from 'sequelize'; + +class Utils { + static uuid(value: string): string { + let id = value; + + if (!validator.isUUID(id)) { + id = uuidv4(); + } + + return id; + } + + static ilike(model: string, column: string, value: string) { + return Sequelize.where( + Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)), + { + [Op.like]: `%${value}%`.toLowerCase(), + }, + ); + } +} + +export default Utils; diff --git a/backend/src/db/with-transaction.ts b/backend/src/db/with-transaction.ts new file mode 100644 index 0000000..6bb8761 --- /dev/null +++ b/backend/src/db/with-transaction.ts @@ -0,0 +1,21 @@ +import type { Transaction } from 'sequelize'; +import db from '@/db/models'; + +/** + * Runs `fn` inside a managed Sequelize transaction: commits on success, rolls + * back and rethrows on failure. Replaces the repeated begin/try/commit/rollback + * boilerplate in the services. + */ +export async function withTransaction( + fn: (transaction: Transaction) => Promise, +): Promise { + const transaction = await db.sequelize.transaction(); + try { + const result = await fn(transaction); + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } +} diff --git a/backend/src/helpers.js b/backend/src/helpers.js deleted file mode 100644 index 46e19bf..0000000 --- a/backend/src/helpers.js +++ /dev/null @@ -1,24 +0,0 @@ -const jwt = require('jsonwebtoken'); -const config = require('./config'); -const { JWT_EXPIRES_IN } = require('./constants/auth'); - -module.exports = class Helpers { - static wrapAsync(fn) { - return function (req, res, next) { - fn(req, res, next).catch(next); - }; - } - - static commonErrorHandler(error, req, res, next) { - if ([400, 403, 404].includes(error.code)) { - return res.status(error.code).send(error.message); - } - - console.error(error); - return res.status(500).send(error.message); - } - - static jwtSign(data) { - return jwt.sign(data, config.secret_key, {expiresIn: JWT_EXPIRES_IN}); - }; -}; diff --git a/backend/src/index.js b/backend/src/index.js deleted file mode 100644 index deaad8d..0000000 --- a/backend/src/index.js +++ /dev/null @@ -1,273 +0,0 @@ - -const express = require('express'); -const cors = require('cors'); -const app = express(); -const passport = require('passport'); -const path = require('path'); -const fs = require('fs'); -const bodyParser = require('body-parser'); -const config = require('./config'); -const csrfOrigin = require('./middlewares/csrf-origin'); -const ForbiddenError = require('./services/notifications/errors/forbidden'); -const swaggerUI = require('swagger-ui-express'); -const swaggerJsDoc = require('swagger-jsdoc'); - -const authRoutes = require('./routes/auth'); -const fileRoutes = require('./routes/file'); -const searchRoutes = require('./routes/search'); -const sqlRoutes = require('./routes/sql'); -const pexelsRoutes = require('./routes/pexels'); -const publicCampusesRoutes = require('./routes/public_campuses'); -const publicContentCatalogRoutes = require('./routes/public_content_catalog'); -const contentCatalogRoutes = require('./routes/content_catalog'); - -const organizationForAuthRoutes = require('./routes/organizationLogin'); - -const openaiRoutes = require('./routes/openai'); - - - -const usersRoutes = require('./routes/users'); - -const rolesRoutes = require('./routes/roles'); - -const permissionsRoutes = require('./routes/permissions'); - -const organizationsRoutes = require('./routes/organizations'); - -const campusesRoutes = require('./routes/campuses'); - -const academic_yearsRoutes = require('./routes/academic_years'); - -const gradesRoutes = require('./routes/grades'); - -const subjectsRoutes = require('./routes/subjects'); - -const studentsRoutes = require('./routes/students'); - -const guardiansRoutes = require('./routes/guardians'); - -const staffRoutes = require('./routes/staff'); - -const classesRoutes = require('./routes/classes'); - -const class_enrollmentsRoutes = require('./routes/class_enrollments'); - -const class_subjectsRoutes = require('./routes/class_subjects'); - -const timetablesRoutes = require('./routes/timetables'); - -const timetable_periodsRoutes = require('./routes/timetable_periods'); - -const attendance_sessionsRoutes = require('./routes/attendance_sessions'); - -const attendance_recordsRoutes = require('./routes/attendance_records'); - -const fee_plansRoutes = require('./routes/fee_plans'); - -const invoicesRoutes = require('./routes/invoices'); - -const paymentsRoutes = require('./routes/payments'); - -const assessmentsRoutes = require('./routes/assessments'); - -const assessment_resultsRoutes = require('./routes/assessment_results'); - -const messagesRoutes = require('./routes/messages'); - -const message_recipientsRoutes = require('./routes/message_recipients'); - -const documentsRoutes = require('./routes/documents'); -const frameEntriesRoutes = require('./routes/frame_entries'); -const userProgressRoutes = require('./routes/user_progress'); -const safetyQuizResultsRoutes = require('./routes/safety_quiz_results'); -const walkthroughCheckinsRoutes = require('./routes/walkthrough_checkins'); -const communicationsRoutes = require('./routes/communications'); -const personalityQuizResultsRoutes = require('./routes/personality_quiz_results'); -const campusAttendanceRoutes = require('./routes/campus_attendance'); -const staffAttendanceRoutes = require('./routes/staff_attendance'); - - -const getBaseUrl = (url) => { - if (!url) return ''; - return url.endsWith('/api') ? url.slice(0, -4) : url; -}; - -const options = { - definition: { - openapi: "3.0.0", - info: { - version: "1.0.0", - title: "School Chain Manager", - description: "School Chain Manager Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.", - }, - servers: [ - { - url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, - description: "Development server", - } - ], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - } - }, - responses: { - UnauthorizedError: { - description: "Access token is missing or invalid" - } - } - }, - security: [{ - bearerAuth: [] - }] - }, - apis: ["./src/routes/*.js"], -}; - -const specs = swaggerJsDoc(options); -app.use('/api-docs', function (req, res, next) { - swaggerUI.host = getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host'); - next() - }, swaggerUI.serve, swaggerUI.setup(specs)) - -app.use(cors({ - credentials: true, - origin(origin, callback) { - if (!origin || config.auth.allowedOrigins.includes(origin)) { - callback(null, origin || true); - return; - } - - callback(new ForbiddenError()); - }, -})); -require('./auth/auth'); - -app.use(bodyParser.json()); -app.use('/api', csrfOrigin); - -app.use('/api/auth', authRoutes); -app.use('/api/file', fileRoutes); -app.use('/api/pexels', pexelsRoutes); -app.use('/api/public/campuses', publicCampusesRoutes); -app.use('/api/public/content-catalog', publicContentCatalogRoutes); -app.enable('trust proxy'); - - -app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes); - -app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoutes); - -app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes); - -app.use('/api/organizations', passport.authenticate('jwt', {session: false}), organizationsRoutes); - -app.use('/api/campuses', passport.authenticate('jwt', {session: false}), campusesRoutes); - -app.use('/api/academic_years', passport.authenticate('jwt', {session: false}), academic_yearsRoutes); - -app.use('/api/grades', passport.authenticate('jwt', {session: false}), gradesRoutes); - -app.use('/api/subjects', passport.authenticate('jwt', {session: false}), subjectsRoutes); - -app.use('/api/students', passport.authenticate('jwt', {session: false}), studentsRoutes); - -app.use('/api/guardians', passport.authenticate('jwt', {session: false}), guardiansRoutes); - -app.use('/api/staff', passport.authenticate('jwt', {session: false}), staffRoutes); - -app.use('/api/classes', passport.authenticate('jwt', {session: false}), classesRoutes); - -app.use('/api/class_enrollments', passport.authenticate('jwt', {session: false}), class_enrollmentsRoutes); - -app.use('/api/class_subjects', passport.authenticate('jwt', {session: false}), class_subjectsRoutes); - -app.use('/api/timetables', passport.authenticate('jwt', {session: false}), timetablesRoutes); - -app.use('/api/timetable_periods', passport.authenticate('jwt', {session: false}), timetable_periodsRoutes); - -app.use('/api/attendance_sessions', passport.authenticate('jwt', {session: false}), attendance_sessionsRoutes); - -app.use('/api/attendance_records', passport.authenticate('jwt', {session: false}), attendance_recordsRoutes); - -app.use('/api/fee_plans', passport.authenticate('jwt', {session: false}), fee_plansRoutes); - -app.use('/api/invoices', passport.authenticate('jwt', {session: false}), invoicesRoutes); - -app.use('/api/payments', passport.authenticate('jwt', {session: false}), paymentsRoutes); - -app.use('/api/assessments', passport.authenticate('jwt', {session: false}), assessmentsRoutes); - -app.use('/api/assessment_results', passport.authenticate('jwt', {session: false}), assessment_resultsRoutes); - -app.use('/api/messages', passport.authenticate('jwt', {session: false}), messagesRoutes); - -app.use('/api/message_recipients', passport.authenticate('jwt', {session: false}), message_recipientsRoutes); - -app.use('/api/documents', passport.authenticate('jwt', {session: false}), documentsRoutes); - -app.use('/api/frame_entries', passport.authenticate('jwt', {session: false}), frameEntriesRoutes); - -app.use('/api/user_progress', passport.authenticate('jwt', {session: false}), userProgressRoutes); - -app.use('/api/safety_quiz_results', passport.authenticate('jwt', {session: false}), safetyQuizResultsRoutes); - -app.use('/api/walkthrough_checkins', passport.authenticate('jwt', {session: false}), walkthroughCheckinsRoutes); -app.use('/api/communications', passport.authenticate('jwt', {session: false}), communicationsRoutes); -app.use('/api/personality_quiz_results', passport.authenticate('jwt', {session: false}), personalityQuizResultsRoutes); -app.use('/api/campus_attendance', passport.authenticate('jwt', {session: false}), campusAttendanceRoutes); -app.use('/api/staff_attendance', passport.authenticate('jwt', {session: false}), staffAttendanceRoutes); -app.use('/api/content-catalog', passport.authenticate('jwt', {session: false}), contentCatalogRoutes); - -app.use( - '/api/openai', - passport.authenticate('jwt', { session: false }), - openaiRoutes, -); -app.use( - '/api/ai', - passport.authenticate('jwt', { session: false }), - openaiRoutes, -); - -app.use( - '/api/search', - passport.authenticate('jwt', { session: false }), - searchRoutes); -app.use( - '/api/sql', - passport.authenticate('jwt', { session: false }), - sqlRoutes); - -app.use( - '/api/org-for-auth', - organizationForAuthRoutes, - ); - - -const publicDir = path.join( - __dirname, - '../public', -); - -if (fs.existsSync(publicDir)) { - app.use('/', express.static(publicDir)); - - app.get('*', function(request, response) { - response.sendFile( - path.resolve(publicDir, 'index.html'), - ); - }); -} - -const PORT = config.serverPort; - - app.listen(PORT, () => { - console.log(`Listening on port ${PORT}`); - }); - -module.exports = app; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..3d88af5 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,195 @@ +import express from 'express'; +import cors from 'cors'; +import passport from 'passport'; +import path from 'path'; +import fs from 'fs'; +import swaggerUI from 'swagger-ui-express'; +import swaggerJsDoc from 'swagger-jsdoc'; +import config from '@/shared/config'; +import csrfOrigin from '@/middlewares/csrf-origin'; +import ForbiddenError from '@/shared/errors/forbidden'; +import { + errorHandler, + notFoundHandler, +} from '@/middlewares/error-handler'; +import logger from '@/shared/logger'; +import '@/auth/auth'; + +import authRoutes from '@/routes/auth'; +import fileRoutes from '@/routes/file'; +import searchRoutes from '@/routes/search'; +import publicCampusesRoutes from '@/routes/public_campuses'; +import publicContentCatalogRoutes from '@/routes/public_content_catalog'; +import contentCatalogRoutes from '@/routes/content_catalog'; +import usersRoutes from '@/routes/users'; +import rolesRoutes from '@/routes/roles'; +import permissionsRoutes from '@/routes/permissions'; +import organizationsRoutes from '@/routes/organizations'; +import campusesRoutes from '@/routes/campuses'; +import academicYearsRoutes from '@/routes/academic_years'; +import gradesRoutes from '@/routes/grades'; +import subjectsRoutes from '@/routes/subjects'; +import studentsRoutes from '@/routes/students'; +import guardiansRoutes from '@/routes/guardians'; +import staffRoutes from '@/routes/staff'; +import classesRoutes from '@/routes/classes'; +import classEnrollmentsRoutes from '@/routes/class_enrollments'; +import classSubjectsRoutes from '@/routes/class_subjects'; +import timetablesRoutes from '@/routes/timetables'; +import timetablePeriodsRoutes from '@/routes/timetable_periods'; +import attendanceSessionsRoutes from '@/routes/attendance_sessions'; +import attendanceRecordsRoutes from '@/routes/attendance_records'; +import feePlansRoutes from '@/routes/fee_plans'; +import invoicesRoutes from '@/routes/invoices'; +import paymentsRoutes from '@/routes/payments'; +import assessmentsRoutes from '@/routes/assessments'; +import assessmentResultsRoutes from '@/routes/assessment_results'; +import messagesRoutes from '@/routes/messages'; +import messageRecipientsRoutes from '@/routes/message_recipients'; +import documentsRoutes from '@/routes/documents'; +import frameEntriesRoutes from '@/routes/frame_entries'; +import userProgressRoutes from '@/routes/user_progress'; +import safetyQuizResultsRoutes from '@/routes/safety_quiz_results'; +import walkthroughCheckinsRoutes from '@/routes/walkthrough_checkins'; +import communicationsRoutes from '@/routes/communications'; +import personalityQuizResultsRoutes from '@/routes/personality_quiz_results'; +import campusAttendanceRoutes from '@/routes/campus_attendance'; +import staffAttendanceRoutes from '@/routes/staff_attendance'; + +const app = express(); + +const authenticated = passport.authenticate('jwt', { session: false }); + +function getBaseUrl(url: string | undefined): string { + if (!url) return ''; + return url.endsWith('/api') ? url.slice(0, -4) : url; +} + +const swaggerOptions: swaggerJsDoc.Options = { + definition: { + openapi: '3.0.0', + info: { + version: '1.0.0', + title: 'School Chain Manager', + description: + 'School Chain Manager Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', + }, + servers: [ + { + url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + responses: { + UnauthorizedError: { + description: 'Access token is missing or invalid', + }, + }, + }, + security: [{ bearerAuth: [] }], + }, + apis: ['./src/routes/*.ts'], +}; + +const specs = swaggerJsDoc(swaggerOptions); +app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs)); + +app.use( + cors({ + credentials: true, + origin(origin, callback) { + if ( + !origin || + config.auth.allowAllOrigins || + config.auth.allowedOrigins.includes(origin) + ) { + callback(null, origin || true); + return; + } + callback(new ForbiddenError()); + }, + }), +); + +app.use(express.json()); +app.use('/api', csrfOrigin); + +app.use('/api/auth', authRoutes); +app.use('/api/file', fileRoutes); +app.use('/api/public/campuses', publicCampusesRoutes); +app.use('/api/public/content-catalog', publicContentCatalogRoutes); +app.enable('trust proxy'); + +app.use('/api/users', authenticated, usersRoutes); +app.use('/api/roles', authenticated, rolesRoutes); +app.use('/api/permissions', authenticated, permissionsRoutes); +app.use('/api/organizations', authenticated, organizationsRoutes); +app.use('/api/campuses', authenticated, campusesRoutes); +app.use('/api/academic_years', authenticated, academicYearsRoutes); +app.use('/api/grades', authenticated, gradesRoutes); +app.use('/api/subjects', authenticated, subjectsRoutes); +app.use('/api/students', authenticated, studentsRoutes); +app.use('/api/guardians', authenticated, guardiansRoutes); +app.use('/api/staff', authenticated, staffRoutes); +app.use('/api/classes', authenticated, classesRoutes); +app.use('/api/class_enrollments', authenticated, classEnrollmentsRoutes); +app.use('/api/class_subjects', authenticated, classSubjectsRoutes); +app.use('/api/timetables', authenticated, timetablesRoutes); +app.use('/api/timetable_periods', authenticated, timetablePeriodsRoutes); +app.use('/api/attendance_sessions', authenticated, attendanceSessionsRoutes); +app.use('/api/attendance_records', authenticated, attendanceRecordsRoutes); +app.use('/api/fee_plans', authenticated, feePlansRoutes); +app.use('/api/invoices', authenticated, invoicesRoutes); +app.use('/api/payments', authenticated, paymentsRoutes); +app.use('/api/assessments', authenticated, assessmentsRoutes); +app.use('/api/assessment_results', authenticated, assessmentResultsRoutes); +app.use('/api/messages', authenticated, messagesRoutes); +app.use('/api/message_recipients', authenticated, messageRecipientsRoutes); +app.use('/api/documents', authenticated, documentsRoutes); +app.use('/api/frame_entries', authenticated, frameEntriesRoutes); +app.use('/api/user_progress', authenticated, userProgressRoutes); +app.use('/api/safety_quiz_results', authenticated, safetyQuizResultsRoutes); +app.use('/api/walkthrough_checkins', authenticated, walkthroughCheckinsRoutes); +app.use('/api/communications', authenticated, communicationsRoutes); +app.use( + '/api/personality_quiz_results', + authenticated, + personalityQuizResultsRoutes, +); +app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes); +app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes); +app.use('/api/content-catalog', authenticated, contentCatalogRoutes); +app.use('/api/search', authenticated, searchRoutes); + +// Unmatched API routes → centralized 404 (the SPA fallback below handles the rest). +app.use('/api', notFoundHandler); + +const __dirname = import.meta.dirname; +const publicDir = path.join(__dirname, '../public'); + +if (fs.existsSync(publicDir)) { + app.use('/', express.static(publicDir)); + + app.get('/*splat', (_request, response) => { + response.sendFile(path.resolve(publicDir, 'index.html')); + }); +} + +// Terminal error middleware — must be registered after all routes/middleware. +app.use(errorHandler); + +const PORT = config.serverPort; + +app.listen(PORT, () => { + logger.info(`Listening on port ${PORT}`); +}); + +export default app; diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js deleted file mode 100644 index 77740c7..0000000 --- a/backend/src/middlewares/check-permissions.js +++ /dev/null @@ -1,149 +0,0 @@ - -const ValidationError = require('../services/notifications/errors/validation'); -const RolesDBApi = require('../db/api/roles'); - -// Cache for the 'Public' role object -let publicRoleCache = null; - -// Function to asynchronously fetch and cache the 'Public' role -async function fetchAndCachePublicRole() { - try { - // Use RolesDBApi to find the role by name 'Public' - publicRoleCache = await RolesDBApi.findBy({ name: 'Public' }); - - if (!publicRoleCache) { - console.error("WARNING: Role 'Public' not found in database during middleware startup. Check your migrations."); - // The system might not function correctly without this role. May need to throw an error or use a fallback stub. - } else { - console.log("'Public' role successfully loaded and cached."); - } - } catch (error) { - console.error("Error fetching 'Public' role during middleware startup:", error); - // Handle the error during startup fetch - throw error; // Important to know if the app can proceed without the Public role - } -} - -// Trigger the role fetching when the check-permissions.js module is imported/loaded -// This should happen during application startup when routes are being configured. -fetchAndCachePublicRole().catch(error => { - // Handle the case where the fetchAndCachePublicRole promise is rejected - console.error("Critical error during permissions middleware initialization:", error); - // Decide here if the process should exit if the Public role is essential. - // process.exit(1); -}); - -/** - * Middleware creator to check if the current user (or Public role) has a specific permission. - * @param {string} permission - The name of the required permission. - * @return {import("express").RequestHandler} Express middleware function. - */ -function checkPermissions(permission) { - return async (req, res, next) => { - const { currentUser } = req; - - // 1. Check self-access bypass (only if the user is authenticated) - if (currentUser && (currentUser.id === req.params.id || currentUser.id === req.body.id)) { - return next(); // User has access to their own resource - } - - // 2. Check Custom Permissions (only if the user is authenticated) - if (currentUser) { - // Ensure custom_permissions is an array before using find - const customPermissions = Array.isArray(currentUser.custom_permissions) - ? currentUser.custom_permissions - : []; - const userPermission = customPermissions.find( - (cp) => cp.name === permission, - ); - if (userPermission) { - return next(); // User has a custom permission - } - } - - // 3. Determine the "effective" role for permission check - let effectiveRole = null; - try { - if (currentUser && currentUser.app_role) { - // User is authenticated and has an assigned role - effectiveRole = currentUser.app_role; - } else { - // User is NOT authenticated OR is authenticated but has no role - // Use the cached 'Public' role - if (!publicRoleCache) { - // If the cache is unexpectedly empty (e.g., startup error caught), - // we can try fetching the role again synchronously (less ideal) or just deny access. - console.error("Public role cache is empty. Attempting synchronous fetch..."); - // Less efficient fallback option: - effectiveRole = await RolesDBApi.findBy({ name: 'Public' }); // Could be slow - if (!effectiveRole) { - // If even the synchronous attempt failed - return next(new Error("Internal Server Error: Public role missing and cannot be fetched.")); - } - } else { - effectiveRole = publicRoleCache; // Use the cached object - } - } - - // Check if we got a valid role object - if (!effectiveRole) { - return next(new Error("Internal Server Error: Could not determine effective role.")); - } - - // 4. Check Permissions on the "effective" role - // Assume the effectiveRole object (from app_role or RolesDBApi) has a getPermissions() method - // or a 'permissions' property (if permissions are eagerly loaded). - let rolePermissions = []; - if (typeof effectiveRole.getPermissions === 'function') { - rolePermissions = await effectiveRole.getPermissions(); // Get permissions asynchronously if the method exists - } else if (Array.isArray(effectiveRole.permissions)) { - rolePermissions = effectiveRole.permissions; // Or take from property if permissions are pre-loaded - } else { - console.error("Role object lacks getPermissions() method or permissions property:", effectiveRole); - return next(new Error("Internal Server Error: Invalid role object format.")); - } - - - if (rolePermissions.find((p) => p.name === permission)) { - next(); // The "effective" role has the required permission - } else { - // The "effective" role does not have the required permission - const roleName = effectiveRole.name || 'unknown role'; - next(new ValidationError('auth.forbidden', `Role '${roleName}' denied access to '${permission}'.`)); - } - - } catch (e) { - // Handle errors during role or permission fetching - console.error("Error during permission check:", e); - next(e); // Pass the error to the next middleware - } - }; -} - -const METHOD_MAP = { - POST: 'CREATE', - GET: 'READ', - PUT: 'UPDATE', - PATCH: 'UPDATE', - DELETE: 'DELETE', -}; - -/** - * Middleware creator to check standard CRUD permissions based on HTTP method and entity name. - * @param {string} name - The name of the entity. - * @return {import("express").RequestHandler} Express middleware function. - */ -function checkCrudPermissions(name) { - return (req, res, next) => { - // Dynamically determine the permission name (e.g., 'READ_USERS') - const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; - // Call the checkPermissions middleware with the determined permission - checkPermissions(permissionName)(req, res, next); - }; -} - -module.exports = { - checkPermissions, - checkCrudPermissions, -}; - diff --git a/backend/src/middlewares/check-permissions.ts b/backend/src/middlewares/check-permissions.ts new file mode 100644 index 0000000..5b920dc --- /dev/null +++ b/backend/src/middlewares/check-permissions.ts @@ -0,0 +1,177 @@ +import { isRecord } from '@/shared/object'; +import logger from '@/shared/logger'; +import type { RequestHandler } from 'express'; +import ValidationError from '@/shared/errors/validation'; +import RolesDBApi from '@/db/api/roles'; +import { SPECIAL_ROLE_NAMES } from '@/shared/constants/roles'; + +// Cache for the 'Public' role record, loaded once at startup. +let publicRoleCache: Record | null = null; + +async function fetchAndCachePublicRole(): Promise { + try { + publicRoleCache = await RolesDBApi.findBy({ name: SPECIAL_ROLE_NAMES.PUBLIC }); + + if (!publicRoleCache) { + logger.error( + "WARNING: Role 'Public' not found in database during middleware startup. Check your migrations.", + ); + } else { + logger.info("'Public' role successfully loaded and cached."); + } + } catch (error) { + logger.error( + "Error fetching 'Public' role during middleware startup:", + error, + ); + throw error; + } +} + +// Trigger role caching at module load (application startup). +fetchAndCachePublicRole().catch((error) => { + logger.error( + 'Critical error during permissions middleware initialization:', + error, + ); +}); + +function permissionNamesFrom(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((p) => (isRecord(p) && typeof p.name === 'string' ? p.name : null)) + .filter((name): name is string => name !== null); +} + +/** + * Resolves the permission names granted by a role, supporting both a loaded + * Sequelize instance (`getPermissions()`) and a plain record (`permissions`). + * Returns null when the role object has neither (invalid format). + */ +async function resolveRolePermissions( + effectiveRole: unknown, +): Promise { + if (!isRecord(effectiveRole)) { + return null; + } + + // Prefer permissions already eager-loaded on the role (the per-request user + // and the cached Public role both carry them), avoiding an extra query. + if (Array.isArray(effectiveRole.permissions)) { + return permissionNamesFrom(effectiveRole.permissions); + } + + const getPermissions = effectiveRole.getPermissions; + if (typeof getPermissions === 'function') { + const perms: unknown = await getPermissions.call(effectiveRole); + return permissionNamesFrom(perms); + } + + return null; +} + +function roleNameOf(effectiveRole: unknown): string { + return isRecord(effectiveRole) && typeof effectiveRole.name === 'string' + ? effectiveRole.name + : 'unknown role'; +} + +/** Middleware: allow the request only if the effective role has `permission`. */ +function checkPermissions(permission: string): RequestHandler { + return async (req, _res, next) => { + const currentUser = req.currentUser; + + // 1. Self-access bypass. + if ( + currentUser && + (currentUser.id === req.params.id || currentUser.id === req.body?.id) + ) { + return next(); + } + + // 2. Custom (per-user) permissions. + if (currentUser) { + const customPermissions = currentUser.custom_permissions ?? []; + if (customPermissions.some((cp) => cp.name === permission)) { + return next(); + } + } + + try { + // 3. Determine the effective role (assigned role or cached 'Public'). + let effectiveRole: unknown; + if (currentUser && currentUser.app_role) { + effectiveRole = currentUser.app_role; + } else if (!publicRoleCache) { + logger.error( + 'Public role cache is empty. Attempting synchronous fetch...', + ); + effectiveRole = await RolesDBApi.findBy({ name: SPECIAL_ROLE_NAMES.PUBLIC }); + if (!effectiveRole) { + return next( + new Error( + 'Internal Server Error: Public role missing and cannot be fetched.', + ), + ); + } + } else { + effectiveRole = publicRoleCache; + } + + if (!effectiveRole) { + return next( + new Error( + 'Internal Server Error: Could not determine effective role.', + ), + ); + } + + // 4. Check the effective role's permissions. + const rolePermissions = await resolveRolePermissions(effectiveRole); + if (rolePermissions === null) { + logger.error( + 'Role object lacks getPermissions() method or permissions property:', + effectiveRole, + ); + return next( + new Error('Internal Server Error: Invalid role object format.'), + ); + } + + if (rolePermissions.includes(permission)) { + next(); + } else { + logger.error( + `Role '${roleNameOf(effectiveRole)}' denied access to '${permission}'.`, + ); + next(new ValidationError('auth.forbidden')); + } + } catch (error) { + logger.error('Error during permission check:', error); + next(error); + } + }; +} + +const METHOD_MAP: Record = { + POST: 'CREATE', + GET: 'READ', + PUT: 'UPDATE', + PATCH: 'UPDATE', + DELETE: 'DELETE', +}; + +/** Middleware: check the CRUD permission derived from method + entity name. */ +function checkCrudPermissions(name: string): RequestHandler { + return (req, res, next) => { + const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`; + checkPermissions(permissionName)(req, res, next); + }; +} + +export default { + checkPermissions, + checkCrudPermissions, +}; diff --git a/backend/src/middlewares/csrf-origin.js b/backend/src/middlewares/csrf-origin.js deleted file mode 100644 index b79aaea..0000000 --- a/backend/src/middlewares/csrf-origin.js +++ /dev/null @@ -1,34 +0,0 @@ -const config = require('../config'); -const { UNSAFE_HTTP_METHODS } = require('../constants/auth'); -const ForbiddenError = require('../services/notifications/errors/forbidden'); - -function getOriginFromHeader(value) { - if (!value) { - return null; - } - - try { - return new URL(value).origin; - } catch { - return null; - } -} - -function csrfOrigin(req, res, next) { - if (!UNSAFE_HTTP_METHODS.includes(req.method)) { - next(); - return; - } - - const sourceOrigin = getOriginFromHeader(req.headers.origin) - || getOriginFromHeader(req.headers.referer); - - if (sourceOrigin && config.auth.allowedOrigins.includes(sourceOrigin)) { - next(); - return; - } - - next(new ForbiddenError()); -} - -module.exports = csrfOrigin; diff --git a/backend/src/middlewares/csrf-origin.ts b/backend/src/middlewares/csrf-origin.ts new file mode 100644 index 0000000..515a3d9 --- /dev/null +++ b/backend/src/middlewares/csrf-origin.ts @@ -0,0 +1,41 @@ +import type { NextFunction, Request, Response } from 'express'; +import config from '@/shared/config'; +import { UNSAFE_HTTP_METHODS } from '@/shared/constants/auth'; +import ForbiddenError from '@/shared/errors/forbidden'; + +function getOriginFromHeader(value: string | undefined): string | null { + if (!value) { + return null; + } + + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function csrfOrigin(req: Request, _res: Response, next: NextFunction): void { + if (!UNSAFE_HTTP_METHODS.includes(req.method)) { + next(); + return; + } + + const sourceOrigin = + getOriginFromHeader(req.headers.origin) || + getOriginFromHeader(req.headers.referer); + + if (config.auth.allowAllOrigins && sourceOrigin) { + next(); + return; + } + + if (sourceOrigin && config.auth.allowedOrigins.includes(sourceOrigin)) { + next(); + return; + } + + next(new ForbiddenError()); +} + +export default csrfOrigin; diff --git a/backend/src/middlewares/error-handler.test.ts b/backend/src/middlewares/error-handler.test.ts new file mode 100644 index 0000000..b2b291a --- /dev/null +++ b/backend/src/middlewares/error-handler.test.ts @@ -0,0 +1,65 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ValidationError as SequelizeValidationError } from 'sequelize'; +import { normalizeError } from '@/middlewares/error-handler'; +import AppError from '@/shared/errors/app-error'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import UnauthorizedError from '@/shared/errors/unauthorized'; +import NotFoundError from '@/shared/errors/not-found'; + +test('ForbiddenError → 403 with message and code', () => { + const result = normalizeError(new ForbiddenError()); + assert.equal(result.status, 403); + assert.equal(result.body.message, 'Forbidden'); + assert.equal(result.body.code, 'errors.forbidden.message'); + assert.equal(result.unexpected, false); +}); + +test('ValidationError → 400', () => { + const result = normalizeError(new ValidationError()); + assert.equal(result.status, 400); + assert.equal(result.unexpected, false); +}); + +test('UnauthorizedError → 401', () => { + assert.equal(normalizeError(new UnauthorizedError()).status, 401); +}); + +test('NotFoundError → 404', () => { + assert.equal(normalizeError(new NotFoundError()).status, 404); +}); + +test('AppError 5xx is marked unexpected (logged)', () => { + const result = normalizeError(new AppError(503, 'down')); + assert.equal(result.status, 503); + assert.equal(result.unexpected, true); +}); + +test('error code (notification key) is preserved when provided', () => { + const result = normalizeError(new ValidationError('auth.forbidden')); + assert.equal(result.body.code, 'auth.forbidden'); +}); + +test('Sequelize ValidationError → client 400 (not a 500)', () => { + const result = normalizeError( + new SequelizeValidationError('Validation error', []), + ); + assert.equal(result.status, 400); + assert.equal(result.body.code, 'errors.validation.message'); + assert.equal(result.unexpected, false); +}); + +test('native Error → generic 500, never leaks internals', () => { + const result = normalizeError(new Error('connect ECONNREFUSED 5432')); + assert.equal(result.status, 500); + assert.notEqual(result.body.message, 'connect ECONNREFUSED 5432'); + assert.equal(result.body.code, 'errors.internal.message'); + assert.equal(result.unexpected, true); +}); + +test('non-Error thrown value → generic 500', () => { + const result = normalizeError('boom'); + assert.equal(result.status, 500); + assert.equal(result.unexpected, true); +}); diff --git a/backend/src/middlewares/error-handler.ts b/backend/src/middlewares/error-handler.ts new file mode 100644 index 0000000..d9e8314 --- /dev/null +++ b/backend/src/middlewares/error-handler.ts @@ -0,0 +1,112 @@ +import type { ErrorRequestHandler, RequestHandler } from 'express'; +import { + BaseError as SequelizeBaseError, + ValidationError as SequelizeValidationError, + UniqueConstraintError, +} from 'sequelize'; +import AppError from '@/shared/errors/app-error'; +import NotFoundError from '@/shared/errors/not-found'; +import { getNotification } from '@/shared/notifications/helpers'; +import logger from '@/shared/logger'; + +/** The error body shape the frontend `ApiError` consumes. */ +export interface ErrorResponseBody { + message: string; + code?: string; + details?: unknown; +} + +export interface NormalizedError { + status: number; + body: ErrorResponseBody; + /** Unexpected errors (5xx) are logged; expected ones (4xx) are not. */ + unexpected: boolean; +} + +function buildBody( + message: string, + code?: string, + details?: unknown, +): ErrorResponseBody { + return { + message, + ...(code !== undefined ? { code } : {}), + ...(details !== undefined ? { details } : {}), + }; +} + +/** + * Maps any thrown value to an HTTP status + client-safe body. Pure (no I/O), so + * it is unit-testable; the middleware below adds logging and the response. + */ +export function normalizeError(error: unknown): NormalizedError { + if (error instanceof AppError) { + return { + status: error.status, + body: buildBody(error.message, error.code, error.details), + unexpected: error.status >= 500, + }; + } + + // Sequelize validation / unique-constraint violations are client errors. The + // per-field messages are developer-defined (safe to surface), unlike raw SQL. + if ( + error instanceof SequelizeValidationError || + error instanceof UniqueConstraintError + ) { + const firstMessage = error.errors?.[0]?.message; + return { + status: 400, + body: buildBody( + firstMessage || getNotification('errors.validation.message'), + 'errors.validation.message', + ), + unexpected: false, + }; + } + + // Any other Sequelize/DB error → generic 400 (never leak SQL or schema). + if (error instanceof SequelizeBaseError) { + return { + status: 400, + body: buildBody( + getNotification('errors.validation.message'), + 'errors.validation.message', + ), + unexpected: false, + }; + } + + // Unknown / native errors → generic 500 (never leak internals to the client). + return { + status: 500, + body: buildBody( + getNotification('errors.internal.message'), + 'errors.internal.message', + ), + unexpected: true, + }; +} + +/** Terminal Express error middleware. Must be registered last. */ +export const errorHandler: ErrorRequestHandler = (error, req, res, next) => { + if (res.headersSent) { + return next(error); + } + + const normalized = normalizeError(error); + + if (normalized.unexpected) { + logger.error('Unhandled request error', error, { + method: req.method, + path: req.originalUrl, + }); + } + + res.status(normalized.status).json(normalized.body); +}; + +/** Catches requests that matched no route and routes them to `errorHandler`. */ +export const notFoundHandler: RequestHandler = (_req, _res, next) => { + next(new NotFoundError()); +}; diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js deleted file mode 100644 index ea3e835..0000000 --- a/backend/src/middlewares/upload.js +++ /dev/null @@ -1,11 +0,0 @@ -const util = require('util'); -const Multer = require('multer'); -const maxSize = 10 * 1024 * 1024; - -let processFile = Multer({ - storage: Multer.memoryStorage(), - limits: { fileSize: maxSize }, -}).single("file"); - -let processFileMiddleware = util.promisify(processFile); -module.exports = processFileMiddleware; diff --git a/backend/src/middlewares/upload.ts b/backend/src/middlewares/upload.ts new file mode 100644 index 0000000..b8f272b --- /dev/null +++ b/backend/src/middlewares/upload.ts @@ -0,0 +1,24 @@ +import Multer from 'multer'; +import type { Request, Response } from 'express'; + +const maxSize = 10 * 1024 * 1024; + +const processFile = Multer({ + storage: Multer.memoryStorage(), + limits: { fileSize: maxSize }, +}).single('file'); + +/** Promise wrapper around the multer single-file middleware. */ +function processFileMiddleware(req: Request, res: Response): Promise { + return new Promise((resolve, reject) => { + processFile(req, res, (error) => { + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + resolve(); + }); + }); +} + +export default processFileMiddleware; diff --git a/backend/src/routes/academic_years.js b/backend/src/routes/academic_years.js deleted file mode 100644 index 7986a17..0000000 --- a/backend/src/routes/academic_years.js +++ /dev/null @@ -1,440 +0,0 @@ - -const express = require('express'); - -const Academic_yearsService = require('../services/academic_years'); -const Academic_yearsDBApi = require('../db/api/academic_years'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('academic_years')); - - -/** - * @swagger - * components: - * schemas: - * Academic_years: - * type: object - * properties: - - * name: - * type: string - * default: name - - - - */ - -/** - * @swagger - * tags: - * name: Academic_years - * description: The Academic_years managing API - */ - -/** -* @swagger -* /api/academic_years: -* post: -* security: -* - bearerAuth: [] -* tags: [Academic_years] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Academic_years" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Academic_years" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Academic_yearsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Academic_years" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Academic_years" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Academic_yearsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/academic_years/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Academic_years" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Academic_years" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Academic_yearsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/academic_years/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Academic_years" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Academic_yearsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/academic_years/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Academic_years" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Academic_yearsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/academic_years: - * get: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Get all academic_years - * description: Get all academic_years - * responses: - * 200: - * description: Academic_years list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Academic_years" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Academic_yearsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name', - - - 'start_date','end_date', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/academic_years/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Count all academic_years - * description: Count all academic_years - * responses: - * 200: - * description: Academic_years count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Academic_years" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Academic_yearsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/academic_years/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Find all academic_years that match search criteria - * description: Find all academic_years that match search criteria - * responses: - * 200: - * description: Academic_years list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Academic_years" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Academic_yearsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/academic_years/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Academic_years] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Academic_years" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Academic_yearsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/academic_years.ts b/backend/src/routes/academic_years.ts new file mode 100644 index 0000000..20909c6 --- /dev/null +++ b/backend/src/routes/academic_years.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/academic_years.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'academic_years' }); diff --git a/backend/src/routes/assessment_results.js b/backend/src/routes/assessment_results.js deleted file mode 100644 index 9126df4..0000000 --- a/backend/src/routes/assessment_results.js +++ /dev/null @@ -1,444 +0,0 @@ - -const express = require('express'); - -const Assessment_resultsService = require('../services/assessment_results'); -const Assessment_resultsDBApi = require('../db/api/assessment_results'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('assessment_results')); - - -/** - * @swagger - * components: - * schemas: - * Assessment_results: - * type: object - * properties: - - * remarks: - * type: string - * default: remarks - - - * score: - * type: integer - * format: int64 - - * - */ - -/** - * @swagger - * tags: - * name: Assessment_results - * description: The Assessment_results managing API - */ - -/** -* @swagger -* /api/assessment_results: -* post: -* security: -* - bearerAuth: [] -* tags: [Assessment_results] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Assessment_results" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Assessment_results" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Assessment_resultsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Assessment_results" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessment_results" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Assessment_resultsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessment_results/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Assessment_results" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessment_results" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Assessment_resultsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessment_results/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessment_results" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Assessment_resultsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessment_results/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessment_results" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Assessment_resultsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/assessment_results: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Get all assessment_results - * description: Get all assessment_results - * responses: - * 200: - * description: Assessment_results list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessment_results" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Assessment_resultsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','remarks', - - 'score', - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/assessment_results/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Count all assessment_results - * description: Count all assessment_results - * responses: - * 200: - * description: Assessment_results count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessment_results" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Assessment_resultsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessment_results/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Find all assessment_results that match search criteria - * description: Find all assessment_results that match search criteria - * responses: - * 200: - * description: Assessment_results list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessment_results" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Assessment_resultsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/assessment_results/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessment_results] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessment_results" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Assessment_resultsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/assessment_results.ts b/backend/src/routes/assessment_results.ts new file mode 100644 index 0000000..a3841ff --- /dev/null +++ b/backend/src/routes/assessment_results.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/assessment_results.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'assessment_results' }); diff --git a/backend/src/routes/assessments.js b/backend/src/routes/assessments.js deleted file mode 100644 index ebc313b..0000000 --- a/backend/src/routes/assessments.js +++ /dev/null @@ -1,448 +0,0 @@ - -const express = require('express'); - -const AssessmentsService = require('../services/assessments'); -const AssessmentsDBApi = require('../db/api/assessments'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('assessments')); - - -/** - * @swagger - * components: - * schemas: - * Assessments: - * type: object - * properties: - - * name: - * type: string - * default: name - * instructions: - * type: string - * default: instructions - - - * max_score: - * type: integer - * format: int64 - - * - * - */ - -/** - * @swagger - * tags: - * name: Assessments - * description: The Assessments managing API - */ - -/** -* @swagger -* /api/assessments: -* post: -* security: -* - bearerAuth: [] -* tags: [Assessments] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Assessments" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Assessments" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await AssessmentsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Assessments" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await AssessmentsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessments/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Assessments" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await AssessmentsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessments/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await AssessmentsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessments/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await AssessmentsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/assessments: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Get all assessments - * description: Get all assessments - * responses: - * 200: - * description: Assessments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await AssessmentsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','instructions', - - 'max_score', - 'assigned_at','due_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/assessments/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Count all assessments - * description: Count all assessments - * responses: - * 200: - * description: Assessments count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await AssessmentsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/assessments/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Find all assessments that match search criteria - * description: Find all assessments that match search criteria - * responses: - * 200: - * description: Assessments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Assessments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await AssessmentsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/assessments/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Assessments] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Assessments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await AssessmentsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/assessments.ts b/backend/src/routes/assessments.ts new file mode 100644 index 0000000..e0ddf0a --- /dev/null +++ b/backend/src/routes/assessments.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/assessments.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'assessments' }); diff --git a/backend/src/routes/attendance_records.js b/backend/src/routes/attendance_records.js deleted file mode 100644 index 7faca06..0000000 --- a/backend/src/routes/attendance_records.js +++ /dev/null @@ -1,444 +0,0 @@ - -const express = require('express'); - -const Attendance_recordsService = require('../services/attendance_records'); -const Attendance_recordsDBApi = require('../db/api/attendance_records'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('attendance_records')); - - -/** - * @swagger - * components: - * schemas: - * Attendance_records: - * type: object - * properties: - - * remarks: - * type: string - * default: remarks - - * minutes_late: - * type: integer - * format: int64 - - - * - */ - -/** - * @swagger - * tags: - * name: Attendance_records - * description: The Attendance_records managing API - */ - -/** -* @swagger -* /api/attendance_records: -* post: -* security: -* - bearerAuth: [] -* tags: [Attendance_records] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Attendance_records" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Attendance_records" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Attendance_recordsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Attendance_records" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_records" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Attendance_recordsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_records/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Attendance_records" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_records" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Attendance_recordsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_records/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_records" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Attendance_recordsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_records/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_records" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Attendance_recordsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/attendance_records: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Get all attendance_records - * description: Get all attendance_records - * responses: - * 200: - * description: Attendance_records list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_records" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Attendance_recordsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','remarks', - 'minutes_late', - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/attendance_records/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Count all attendance_records - * description: Count all attendance_records - * responses: - * 200: - * description: Attendance_records count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_records" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Attendance_recordsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_records/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Find all attendance_records that match search criteria - * description: Find all attendance_records that match search criteria - * responses: - * 200: - * description: Attendance_records list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_records" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Attendance_recordsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/attendance_records/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_records] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_records" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Attendance_recordsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/attendance_records.ts b/backend/src/routes/attendance_records.ts new file mode 100644 index 0000000..1f68244 --- /dev/null +++ b/backend/src/routes/attendance_records.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/attendance_records.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'attendance_records' }); diff --git a/backend/src/routes/attendance_sessions.js b/backend/src/routes/attendance_sessions.js deleted file mode 100644 index 68e1a84..0000000 --- a/backend/src/routes/attendance_sessions.js +++ /dev/null @@ -1,441 +0,0 @@ - -const express = require('express'); - -const Attendance_sessionsService = require('../services/attendance_sessions'); -const Attendance_sessionsDBApi = require('../db/api/attendance_sessions'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('attendance_sessions')); - - -/** - * @swagger - * components: - * schemas: - * Attendance_sessions: - * type: object - * properties: - - * notes: - * type: string - * default: notes - - - - * - */ - -/** - * @swagger - * tags: - * name: Attendance_sessions - * description: The Attendance_sessions managing API - */ - -/** -* @swagger -* /api/attendance_sessions: -* post: -* security: -* - bearerAuth: [] -* tags: [Attendance_sessions] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Attendance_sessions" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Attendance_sessions" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Attendance_sessionsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Attendance_sessions" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_sessions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Attendance_sessionsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_sessions/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Attendance_sessions" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_sessions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Attendance_sessionsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_sessions/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_sessions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Attendance_sessionsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_sessions/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_sessions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Attendance_sessionsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/attendance_sessions: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Get all attendance_sessions - * description: Get all attendance_sessions - * responses: - * 200: - * description: Attendance_sessions list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_sessions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Attendance_sessionsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','notes', - - - 'session_date', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/attendance_sessions/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Count all attendance_sessions - * description: Count all attendance_sessions - * responses: - * 200: - * description: Attendance_sessions count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_sessions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Attendance_sessionsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/attendance_sessions/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Find all attendance_sessions that match search criteria - * description: Find all attendance_sessions that match search criteria - * responses: - * 200: - * description: Attendance_sessions list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Attendance_sessions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Attendance_sessionsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/attendance_sessions/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Attendance_sessions] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Attendance_sessions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Attendance_sessionsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/attendance_sessions.ts b/backend/src/routes/attendance_sessions.ts new file mode 100644 index 0000000..71ed935 --- /dev/null +++ b/backend/src/routes/attendance_sessions.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/attendance_sessions.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'attendance_sessions' }); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js deleted file mode 100644 index 4b67e79..0000000 --- a/backend/src/routes/auth.js +++ /dev/null @@ -1,237 +0,0 @@ -const express = require('express'); -const passport = require('passport'); - -const config = require('../config'); -const AuthService = require('../services/auth'); -const ForbiddenError = require('../services/notifications/errors/forbidden'); -const EmailSender = require('../services/email'); -const wrapAsync = require('../helpers').wrapAsync; -const { - clearSessionCookies, - extractRefreshCookie, - setSessionCookies, -} = require('../auth/cookies'); - -const router = express.Router(); - -/** - * @swagger - * components: - * schemas: - * Auth: - * type: object - * required: - * - email - * - password - * properties: - * email: - * type: string - * default: admin@flatlogic.com - * description: User email - * password: - * type: string - * default: password - * description: User password - */ - -/** - * @swagger - * tags: - * name: Auth - * description: Authorization operations - */ - -/** - * @swagger - * /api/auth/signin/local: - * post: - * tags: [Auth] - * summary: Logs user into the system - * description: Logs user into the system - * requestBody: - * description: Set valid user email and password - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Auth" - * responses: - * 200: - * description: Successful login - * 400: - * description: Invalid username/password supplied - * x-codegen-request-body-name: body - */ - -router.post('/signin/local', wrapAsync(async (req, res) => { - const result = await AuthService.signin(req.body.email, req.body.password); - const session = await AuthService.createSession(result.user, req); - setSessionCookies(res, session); - const payload = await AuthService.currentUserProfile(result.user); - res.status(200).send(payload); -})); - -router.post('/refresh', wrapAsync(async (req, res) => { - const session = await AuthService.refreshSession( - extractRefreshCookie(req), - req, - ); - setSessionCookies(res, session); - const payload = await AuthService.currentUserProfile(session.user); - res.status(200).send(payload); -})); - -router.post('/signout', wrapAsync(async (req, res) => { - await AuthService.revokeSession(extractRefreshCookie(req)); - clearSessionCookies(res); - res.status(204).send(); -})); - -/** - * @swagger - * /api/auth/me: - * get: - * security: - * - bearerAuth: [] - * tags: [Auth] - * summary: Get current authorized user info - * description: Get current authorized user info - * responses: - * 200: - * description: Successful retrieval of current authorized user data - * 400: - * description: Invalid username/password supplied - * x-codegen-request-body-name: body - */ - -router.get('/me', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => { - if (!req.currentUser || !req.currentUser.id) { - throw new ForbiddenError(); - } - - const payload = await AuthService.currentUserProfile(req.currentUser); - res.status(200).send(payload); -})); - -router.put('/password-reset', wrapAsync(async (req, res) => { - const payload = await AuthService.passwordReset(req.body.token, req.body.password, req,); - res.status(200).send(payload); -})); - -router.put('/password-update', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => { - const payload = await AuthService.passwordUpdate(req.body.currentPassword, req.body.newPassword, req); - res.status(200).send(payload); -})); - -router.post('/send-email-address-verification-email', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => { - if (!req.currentUser) { - throw new ForbiddenError(); - } - - await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); - const payload = true; - res.status(200).send(payload); -})); - -router.post('/send-password-reset-email', wrapAsync(async (req, res) => { - const link = new URL(req.headers.referer); - await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.host,); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/auth/signup: - * post: - * tags: [Auth] - * summary: Register new user into the system - * description: Register new user into the system - * requestBody: - * description: Set valid user email and password - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Auth" - * responses: - * 200: - * description: New user successfully signed up - * 400: - * description: Invalid username/password supplied - * 500: - * description: Some server error - * x-codegen-request-body-name: body - */ - -router.post('/signup', wrapAsync(async (req, res) => { - const link = new URL(req.headers.referer); - 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); - setSessionCookies(res, session); - const payload = await AuthService.currentUserProfile(result.user); - res.status(200).send(payload); -})); - -router.put('/profile', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => { - if (!req.currentUser || !req.currentUser.id) { - throw new ForbiddenError(); - } - - await AuthService.updateProfile(req.body.profile, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -router.put('/verify-email', wrapAsync(async (req, res) => { - const payload = await AuthService.verifyEmail(req.body.token, req, req.headers.referer) - res.status(200).send(payload); -})); - -router.get('/email-configured', (req, res) => { - const payload = EmailSender.isConfigured; - res.status(200).send(payload); -}); - -router.get('/signin/google', (req, res, next) => { - passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next); -}); - -router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}), - - wrapAsync(async (req, res) => { - await socialRedirect(req, res, req.user.user, config); - } -)); - -router.get('/signin/microsoft', (req, res, next) => { - passport.authenticate("microsoft", { - scope: ["https://graph.microsoft.com/user.read openid"], - state: req.query.app - })(req, res, next); -}); - -router.get('/signin/microsoft/callback', passport.authenticate("microsoft", { - failureRedirect: "/login", - session: false - }), - wrapAsync(async (req, res) => { - await socialRedirect(req, res, req.user.user, config); - } -)); - -router.use('/', require('../helpers').commonErrorHandler); - -async function socialRedirect(req, res, user, config) { - const session = await AuthService.createSession(user, req); - setSessionCookies(res, session); - res.redirect(config.uiUrl); -} - -module.exports = router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..2169fcb --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,43 @@ +import express from 'express'; +import passport from 'passport'; +import { wrapAsync } from '@/api/http/request'; +import * as auth from '@/api/controllers/auth.controller'; + +const router = express.Router(); + +const authenticated = passport.authenticate('jwt', { session: false }); + +router.post('/signin/local', wrapAsync(auth.signinLocal)); +router.post('/refresh', wrapAsync(auth.refresh)); +router.post('/signout', wrapAsync(auth.signout)); +router.get('/me', authenticated, wrapAsync(auth.me)); +router.put('/password-reset', wrapAsync(auth.passwordReset)); +router.put('/password-update', authenticated, wrapAsync(auth.passwordUpdate)); +router.post( + '/send-email-address-verification-email', + authenticated, + wrapAsync(auth.sendEmailVerification), +); +router.post('/send-password-reset-email', wrapAsync(auth.sendPasswordResetEmail)); +router.post('/signup', wrapAsync(auth.signup)); +router.put('/profile', authenticated, wrapAsync(auth.updateProfile)); +router.put('/verify-email', wrapAsync(auth.verifyEmail)); +router.get('/email-configured', auth.emailConfigured); + +router.get('/signin/google', auth.googleSignin); +router.get( + '/signin/google/callback', + passport.authenticate('google', { failureRedirect: '/login', session: false }), + wrapAsync(auth.googleCallback), +); +router.get('/signin/microsoft', auth.microsoftSignin); +router.get( + '/signin/microsoft/callback', + passport.authenticate('microsoft', { + failureRedirect: '/login', + session: false, + }), + wrapAsync(auth.microsoftCallback), +); + +export default router; diff --git a/backend/src/routes/campus_attendance.js b/backend/src/routes/campus_attendance.js deleted file mode 100644 index 815c65d..0000000 --- a/backend/src/routes/campus_attendance.js +++ /dev/null @@ -1,34 +0,0 @@ -const express = require('express'); -const CampusAttendanceService = require('../services/campus_attendance'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/configs', wrapAsync(async (req, res) => { - const payload = await CampusAttendanceService.listConfigs(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.put('/configs/:campusKey', wrapAsync(async (req, res) => { - const payload = await CampusAttendanceService.upsertConfig(req.params.campusKey, req.body.data, req.currentUser); - res.status(200).send(payload); -})); - -router.get('/summaries', wrapAsync(async (req, res) => { - const payload = await CampusAttendanceService.listSummaries(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.put('/summaries/:campusKey/:date', wrapAsync(async (req, res) => { - const payload = await CampusAttendanceService.upsertSummary( - req.params.campusKey, - req.params.date, - req.body.data, - req.currentUser, - ); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/campus_attendance.ts b/backend/src/routes/campus_attendance.ts new file mode 100644 index 0000000..f0957e8 --- /dev/null +++ b/backend/src/routes/campus_attendance.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as campusAttendance from '@/api/controllers/campus_attendance.controller'; + +const router = express.Router(); + +router.get('/configs', wrapAsync(campusAttendance.listConfigs)); +router.put('/configs/:campusKey', wrapAsync(campusAttendance.upsertConfig)); +router.get('/summaries', wrapAsync(campusAttendance.listSummaries)); +router.put( + '/summaries/:campusKey/:date', + wrapAsync(campusAttendance.upsertSummary), +); + +export default router; diff --git a/backend/src/routes/campuses.js b/backend/src/routes/campuses.js deleted file mode 100644 index e2c9bf3..0000000 --- a/backend/src/routes/campuses.js +++ /dev/null @@ -1,452 +0,0 @@ - -const express = require('express'); - -const CampusesService = require('../services/campuses'); -const CampusesDBApi = require('../db/api/campuses'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('campuses')); - - -/** - * @swagger - * components: - * schemas: - * Campuses: - * type: object - * properties: - - * name: - * type: string - * default: name - * code: - * type: string - * default: code - * address: - * type: string - * default: address - * phone: - * type: string - * default: phone - * email: - * type: string - * default: email - - - - */ - -/** - * @swagger - * tags: - * name: Campuses - * description: The Campuses managing API - */ - -/** -* @swagger -* /api/campuses: -* post: -* security: -* - bearerAuth: [] -* tags: [Campuses] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Campuses" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Campuses" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await CampusesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Campuses" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Campuses" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await CampusesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/campuses/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Campuses" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Campuses" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await CampusesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/campuses/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Campuses" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await CampusesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/campuses/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Campuses" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await CampusesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/campuses: - * get: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Get all campuses - * description: Get all campuses - * responses: - * 200: - * description: Campuses list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Campuses" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await CampusesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','code','address','phone','email', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/campuses/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Count all campuses - * description: Count all campuses - * responses: - * 200: - * description: Campuses count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Campuses" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await CampusesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/campuses/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Find all campuses that match search criteria - * description: Find all campuses that match search criteria - * responses: - * 200: - * description: Campuses list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Campuses" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await CampusesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/campuses/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Campuses] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Campuses" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await CampusesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/campuses.ts b/backend/src/routes/campuses.ts new file mode 100644 index 0000000..059e78d --- /dev/null +++ b/backend/src/routes/campuses.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/campuses.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'campuses' }); diff --git a/backend/src/routes/class_enrollments.js b/backend/src/routes/class_enrollments.js deleted file mode 100644 index e2678b8..0000000 --- a/backend/src/routes/class_enrollments.js +++ /dev/null @@ -1,438 +0,0 @@ - -const express = require('express'); - -const Class_enrollmentsService = require('../services/class_enrollments'); -const Class_enrollmentsDBApi = require('../db/api/class_enrollments'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('class_enrollments')); - - -/** - * @swagger - * components: - * schemas: - * Class_enrollments: - * type: object - * properties: - - - - - * - */ - -/** - * @swagger - * tags: - * name: Class_enrollments - * description: The Class_enrollments managing API - */ - -/** -* @swagger -* /api/class_enrollments: -* post: -* security: -* - bearerAuth: [] -* tags: [Class_enrollments] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Class_enrollments" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Class_enrollments" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Class_enrollmentsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Class_enrollments" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_enrollments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Class_enrollmentsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_enrollments/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Class_enrollments" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_enrollments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Class_enrollmentsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_enrollments/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_enrollments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Class_enrollmentsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_enrollments/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_enrollments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Class_enrollmentsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/class_enrollments: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Get all class_enrollments - * description: Get all class_enrollments - * responses: - * 200: - * description: Class_enrollments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_enrollments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Class_enrollmentsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id', - - - 'enrolled_on','ended_on', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/class_enrollments/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Count all class_enrollments - * description: Count all class_enrollments - * responses: - * 200: - * description: Class_enrollments count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_enrollments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Class_enrollmentsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_enrollments/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Find all class_enrollments that match search criteria - * description: Find all class_enrollments that match search criteria - * responses: - * 200: - * description: Class_enrollments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_enrollments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Class_enrollmentsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/class_enrollments/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_enrollments] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_enrollments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Class_enrollmentsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/class_enrollments.ts b/backend/src/routes/class_enrollments.ts new file mode 100644 index 0000000..67bbd35 --- /dev/null +++ b/backend/src/routes/class_enrollments.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/class_enrollments.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'class_enrollments' }); diff --git a/backend/src/routes/class_subjects.js b/backend/src/routes/class_subjects.js deleted file mode 100644 index 1ac72de..0000000 --- a/backend/src/routes/class_subjects.js +++ /dev/null @@ -1,438 +0,0 @@ - -const express = require('express'); - -const Class_subjectsService = require('../services/class_subjects'); -const Class_subjectsDBApi = require('../db/api/class_subjects'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('class_subjects')); - - -/** - * @swagger - * components: - * schemas: - * Class_subjects: - * type: object - * properties: - - - - - * - */ - -/** - * @swagger - * tags: - * name: Class_subjects - * description: The Class_subjects managing API - */ - -/** -* @swagger -* /api/class_subjects: -* post: -* security: -* - bearerAuth: [] -* tags: [Class_subjects] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Class_subjects" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Class_subjects" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Class_subjectsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Class_subjects" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Class_subjectsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_subjects/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Class_subjects" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Class_subjectsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_subjects/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Class_subjectsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_subjects/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Class_subjectsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/class_subjects: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Get all class_subjects - * description: Get all class_subjects - * responses: - * 200: - * description: Class_subjects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Class_subjectsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/class_subjects/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Count all class_subjects - * description: Count all class_subjects - * responses: - * 200: - * description: Class_subjects count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Class_subjectsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/class_subjects/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Find all class_subjects that match search criteria - * description: Find all class_subjects that match search criteria - * responses: - * 200: - * description: Class_subjects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Class_subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Class_subjectsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/class_subjects/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Class_subjects] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Class_subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Class_subjectsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/class_subjects.ts b/backend/src/routes/class_subjects.ts new file mode 100644 index 0000000..33c57f4 --- /dev/null +++ b/backend/src/routes/class_subjects.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/class_subjects.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'class_subjects' }); diff --git a/backend/src/routes/classes.js b/backend/src/routes/classes.js deleted file mode 100644 index 4498b64..0000000 --- a/backend/src/routes/classes.js +++ /dev/null @@ -1,447 +0,0 @@ - -const express = require('express'); - -const ClassesService = require('../services/classes'); -const ClassesDBApi = require('../db/api/classes'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('classes')); - - -/** - * @swagger - * components: - * schemas: - * Classes: - * type: object - * properties: - - * name: - * type: string - * default: name - * section: - * type: string - * default: section - - * capacity: - * type: integer - * format: int64 - - - * - */ - -/** - * @swagger - * tags: - * name: Classes - * description: The Classes managing API - */ - -/** -* @swagger -* /api/classes: -* post: -* security: -* - bearerAuth: [] -* tags: [Classes] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Classes" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Classes" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await ClassesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Classes" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Classes" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await ClassesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/classes/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Classes" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Classes" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await ClassesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/classes/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Classes" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await ClassesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/classes/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Classes" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await ClassesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/classes: - * get: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Get all classes - * description: Get all classes - * responses: - * 200: - * description: Classes list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Classes" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await ClassesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','section', - 'capacity', - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/classes/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Count all classes - * description: Count all classes - * responses: - * 200: - * description: Classes count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Classes" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await ClassesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/classes/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Find all classes that match search criteria - * description: Find all classes that match search criteria - * responses: - * 200: - * description: Classes list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Classes" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await ClassesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/classes/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Classes] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Classes" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await ClassesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts new file mode 100644 index 0000000..37bf922 --- /dev/null +++ b/backend/src/routes/classes.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/classes.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'classes' }); diff --git a/backend/src/routes/communications.js b/backend/src/routes/communications.js deleted file mode 100644 index 3c49b14..0000000 --- a/backend/src/routes/communications.js +++ /dev/null @@ -1,29 +0,0 @@ -const express = require('express'); -const CommunicationsService = require('../services/communications'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/parent-messages', wrapAsync(async (req, res) => { - const payload = await CommunicationsService.listParentMessages(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.post('/parent-messages', wrapAsync(async (req, res) => { - const payload = await CommunicationsService.createParentMessage(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.get('/events', wrapAsync(async (req, res) => { - const payload = await CommunicationsService.listEvents(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.post('/events', wrapAsync(async (req, res) => { - const payload = await CommunicationsService.createEvent(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/communications.ts b/backend/src/routes/communications.ts new file mode 100644 index 0000000..75271a3 --- /dev/null +++ b/backend/src/routes/communications.ts @@ -0,0 +1,12 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as communications from '@/api/controllers/communications.controller'; + +const router = express.Router(); + +router.get('/parent-messages', wrapAsync(communications.listParentMessages)); +router.post('/parent-messages', wrapAsync(communications.createParentMessage)); +router.get('/events', wrapAsync(communications.listEvents)); +router.post('/events', wrapAsync(communications.createEvent)); + +export default router; diff --git a/backend/src/routes/content_catalog.js b/backend/src/routes/content_catalog.js deleted file mode 100644 index 15d16e3..0000000 --- a/backend/src/routes/content_catalog.js +++ /dev/null @@ -1,35 +0,0 @@ -const express = require('express'); - -const ContentCatalogService = require('../services/content_catalog'); -const wrapAsync = require('../helpers').wrapAsync; - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await ContentCatalogService.list(req.currentUser); - res.status(200).send(payload); -})); - -router.post('/', wrapAsync(async (req, res) => { - const payload = await ContentCatalogService.create(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.get('/:contentType', wrapAsync(async (req, res) => { - const payload = await ContentCatalogService.findManagedByType(req.params.contentType, req.currentUser); - res.status(200).send(payload); -})); - -router.put('/:contentType', wrapAsync(async (req, res) => { - const payload = await ContentCatalogService.update(req.params.contentType, req.body.data, req.currentUser); - res.status(200).send(payload); -})); - -router.delete('/:contentType', wrapAsync(async (req, res) => { - await ContentCatalogService.delete(req.params.contentType, req.currentUser); - res.status(204).send(); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/content_catalog.ts b/backend/src/routes/content_catalog.ts new file mode 100644 index 0000000..b7c4c04 --- /dev/null +++ b/backend/src/routes/content_catalog.ts @@ -0,0 +1,13 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as content_catalog from '@/api/controllers/content_catalog.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(content_catalog.list)); +router.post('/', wrapAsync(content_catalog.create)); +router.get('/:contentType', wrapAsync(content_catalog.findManagedByType)); +router.put('/:contentType', wrapAsync(content_catalog.update)); +router.delete('/:contentType', wrapAsync(content_catalog.remove)); + +export default router; diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js deleted file mode 100644 index 53e9776..0000000 --- a/backend/src/routes/documents.js +++ /dev/null @@ -1,448 +0,0 @@ - -const express = require('express'); - -const DocumentsService = require('../services/documents'); -const DocumentsDBApi = require('../db/api/documents'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('documents')); - - -/** - * @swagger - * components: - * schemas: - * Documents: - * type: object - * properties: - - * entity_reference: - * type: string - * default: entity_reference - * name: - * type: string - * default: name - * notes: - * type: string - * default: notes - - - - * - * - */ - -/** - * @swagger - * tags: - * name: Documents - * description: The Documents managing API - */ - -/** -* @swagger -* /api/documents: -* post: -* security: -* - bearerAuth: [] -* tags: [Documents] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Documents" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Documents" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await DocumentsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Documents" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Documents" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await DocumentsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/documents/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Documents" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Documents" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await DocumentsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/documents/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Documents" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await DocumentsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/documents/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Documents" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await DocumentsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/documents: - * get: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Get all documents - * description: Get all documents - * responses: - * 200: - * description: Documents list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Documents" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await DocumentsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','entity_reference','name','notes', - - - 'uploaded_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/documents/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Count all documents - * description: Count all documents - * responses: - * 200: - * description: Documents count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Documents" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await DocumentsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/documents/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Find all documents that match search criteria - * description: Find all documents that match search criteria - * responses: - * 200: - * description: Documents list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Documents" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await DocumentsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/documents/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Documents] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Documents" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await DocumentsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts new file mode 100644 index 0000000..30f3ba8 --- /dev/null +++ b/backend/src/routes/documents.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as documents from '@/api/controllers/documents.controller'; +import permissions from '@/middlewares/check-permissions'; + +const router = express.Router(); + +router.use(permissions.checkCrudPermissions('documents')); + +router.post('/', wrapAsync(documents.create)); +router.post('/bulk-import', wrapAsync(documents.bulkImport)); +router.put('/:id', wrapAsync(documents.update)); +router.delete('/:id', wrapAsync(documents.remove)); +router.post('/deleteByIds', wrapAsync(documents.deleteByIds)); +router.get('/', wrapAsync(documents.list)); +router.get('/count', wrapAsync(documents.count)); +router.get('/autocomplete', wrapAsync(documents.autocomplete)); +router.get('/:id', wrapAsync(documents.findById)); + +export default router; diff --git a/backend/src/routes/fee_plans.js b/backend/src/routes/fee_plans.js deleted file mode 100644 index 307548e..0000000 --- a/backend/src/routes/fee_plans.js +++ /dev/null @@ -1,447 +0,0 @@ - -const express = require('express'); - -const Fee_plansService = require('../services/fee_plans'); -const Fee_plansDBApi = require('../db/api/fee_plans'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('fee_plans')); - - -/** - * @swagger - * components: - * schemas: - * Fee_plans: - * type: object - * properties: - - * name: - * type: string - * default: name - * notes: - * type: string - * default: notes - - - * total_amount: - * type: integer - * format: int64 - - * - */ - -/** - * @swagger - * tags: - * name: Fee_plans - * description: The Fee_plans managing API - */ - -/** -* @swagger -* /api/fee_plans: -* post: -* security: -* - bearerAuth: [] -* tags: [Fee_plans] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Fee_plans" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Fee_plans" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Fee_plansService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Fee_plans" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Fee_plans" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Fee_plansService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/fee_plans/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Fee_plans" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Fee_plans" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Fee_plansService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/fee_plans/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Fee_plans" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Fee_plansService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/fee_plans/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Fee_plans" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Fee_plansService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/fee_plans: - * get: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Get all fee_plans - * description: Get all fee_plans - * responses: - * 200: - * description: Fee_plans list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Fee_plans" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Fee_plansDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','notes', - - 'total_amount', - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/fee_plans/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Count all fee_plans - * description: Count all fee_plans - * responses: - * 200: - * description: Fee_plans count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Fee_plans" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Fee_plansDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/fee_plans/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Find all fee_plans that match search criteria - * description: Find all fee_plans that match search criteria - * responses: - * 200: - * description: Fee_plans list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Fee_plans" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Fee_plansDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/fee_plans/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Fee_plans] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Fee_plans" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Fee_plansDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/fee_plans.ts b/backend/src/routes/fee_plans.ts new file mode 100644 index 0000000..2404572 --- /dev/null +++ b/backend/src/routes/fee_plans.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/fee_plans.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'fee_plans' }); diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js deleted file mode 100644 index ddd2bc0..0000000 --- a/backend/src/routes/file.js +++ /dev/null @@ -1,32 +0,0 @@ -const express = require('express'); -const config = require('../config'); -const path = require('path'); -const passport = require('passport'); -const services = require('../services/file'); -const router = express.Router(); - -router.get('/download', (req, res) => { - if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { - services.downloadGCloud(req, res); - } - else { - services.downloadLocal(req, res); - } -}); - -router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => { - const fileName = `${req.params.table}/${req.params.field}`; - - if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { - services.uploadGCloud(fileName, req, res); - } - else { - services.uploadLocal(fileName, { - entity: null, - maxFileSize: 10 * 1024 * 1024, - folderIncludesAuthenticationUid: false, - })(req, res); - } -}); - -module.exports = router; diff --git a/backend/src/routes/file.ts b/backend/src/routes/file.ts new file mode 100644 index 0000000..f69d1ca --- /dev/null +++ b/backend/src/routes/file.ts @@ -0,0 +1,15 @@ +import express from 'express'; +import passport from 'passport'; +import * as file from '@/api/controllers/file.controller'; + +const router = express.Router(); + +router.get('/download', file.download); + +router.post( + '/upload/:table/:field', + passport.authenticate('jwt', { session: false }), + file.upload, +); + +export default router; diff --git a/backend/src/routes/frame_entries.js b/backend/src/routes/frame_entries.js deleted file mode 100644 index f78e5eb..0000000 --- a/backend/src/routes/frame_entries.js +++ /dev/null @@ -1,24 +0,0 @@ -const express = require('express'); -const FrameEntriesService = require('../services/frame_entries'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await FrameEntriesService.list(req.currentUser); - res.status(200).send(payload); -})); - -router.post('/', wrapAsync(async (req, res) => { - const payload = await FrameEntriesService.create(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.put('/:id', wrapAsync(async (req, res) => { - const payload = await FrameEntriesService.update(req.params.id, req.body.data, req.currentUser); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/frame_entries.ts b/backend/src/routes/frame_entries.ts new file mode 100644 index 0000000..ea42f02 --- /dev/null +++ b/backend/src/routes/frame_entries.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as frame from '@/api/controllers/frame_entries.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(frame.list)); +router.post('/', wrapAsync(frame.create)); +router.put('/:id', wrapAsync(frame.update)); + +export default router; diff --git a/backend/src/routes/grades.js b/backend/src/routes/grades.js deleted file mode 100644 index 7559426..0000000 --- a/backend/src/routes/grades.js +++ /dev/null @@ -1,449 +0,0 @@ - -const express = require('express'); - -const GradesService = require('../services/grades'); -const GradesDBApi = require('../db/api/grades'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('grades')); - - -/** - * @swagger - * components: - * schemas: - * Grades: - * type: object - * properties: - - * name: - * type: string - * default: name - * code: - * type: string - * default: code - * description: - * type: string - * default: description - - * sort_order: - * type: integer - * format: int64 - - - */ - -/** - * @swagger - * tags: - * name: Grades - * description: The Grades managing API - */ - -/** -* @swagger -* /api/grades: -* post: -* security: -* - bearerAuth: [] -* tags: [Grades] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Grades" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Grades" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await GradesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Grades" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Grades" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await GradesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/grades/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Grades" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Grades" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await GradesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/grades/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Grades" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await GradesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/grades/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Grades" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await GradesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/grades: - * get: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Get all grades - * description: Get all grades - * responses: - * 200: - * description: Grades list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Grades" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await GradesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','code','description', - 'sort_order', - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/grades/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Count all grades - * description: Count all grades - * responses: - * 200: - * description: Grades count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Grades" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await GradesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/grades/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Find all grades that match search criteria - * description: Find all grades that match search criteria - * responses: - * 200: - * description: Grades list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Grades" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await GradesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/grades/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Grades] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Grades" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await GradesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/grades.ts b/backend/src/routes/grades.ts new file mode 100644 index 0000000..f130e33 --- /dev/null +++ b/backend/src/routes/grades.ts @@ -0,0 +1,4 @@ +import gradesController from '@/api/controllers/grades.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(gradesController, { permission: 'grades' }); diff --git a/backend/src/routes/guardians.js b/backend/src/routes/guardians.js deleted file mode 100644 index 0503d0b..0000000 --- a/backend/src/routes/guardians.js +++ /dev/null @@ -1,450 +0,0 @@ - -const express = require('express'); - -const GuardiansService = require('../services/guardians'); -const GuardiansDBApi = require('../db/api/guardians'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('guardians')); - - -/** - * @swagger - * components: - * schemas: - * Guardians: - * type: object - * properties: - - * full_name: - * type: string - * default: full_name - * phone: - * type: string - * default: phone - * email: - * type: string - * default: email - * address: - * type: string - * default: address - - - - * - */ - -/** - * @swagger - * tags: - * name: Guardians - * description: The Guardians managing API - */ - -/** -* @swagger -* /api/guardians: -* post: -* security: -* - bearerAuth: [] -* tags: [Guardians] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Guardians" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Guardians" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await GuardiansService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Guardians" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Guardians" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await GuardiansService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/guardians/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Guardians" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Guardians" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await GuardiansService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/guardians/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Guardians" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await GuardiansService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/guardians/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Guardians" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await GuardiansService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/guardians: - * get: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Get all guardians - * description: Get all guardians - * responses: - * 200: - * description: Guardians list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Guardians" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await GuardiansDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','full_name','phone','email','address', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/guardians/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Count all guardians - * description: Count all guardians - * responses: - * 200: - * description: Guardians count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Guardians" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await GuardiansDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/guardians/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Find all guardians that match search criteria - * description: Find all guardians that match search criteria - * responses: - * 200: - * description: Guardians list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Guardians" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await GuardiansDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/guardians/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Guardians] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Guardians" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await GuardiansDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/guardians.ts b/backend/src/routes/guardians.ts new file mode 100644 index 0000000..e0a68fa --- /dev/null +++ b/backend/src/routes/guardians.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/guardians.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'guardians' }); diff --git a/backend/src/routes/invoices.js b/backend/src/routes/invoices.js deleted file mode 100644 index 6a7ecd7..0000000 --- a/backend/src/routes/invoices.js +++ /dev/null @@ -1,459 +0,0 @@ - -const express = require('express'); - -const InvoicesService = require('../services/invoices'); -const InvoicesDBApi = require('../db/api/invoices'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('invoices')); - - -/** - * @swagger - * components: - * schemas: - * Invoices: - * type: object - * properties: - - * invoice_number: - * type: string - * default: invoice_number - * notes: - * type: string - * default: notes - - - * subtotal: - * type: integer - * format: int64 - * discount_amount: - * type: integer - * format: int64 - * tax_amount: - * type: integer - * format: int64 - * total_amount: - * type: integer - * format: int64 - * balance_due: - * type: integer - * format: int64 - - * - */ - -/** - * @swagger - * tags: - * name: Invoices - * description: The Invoices managing API - */ - -/** -* @swagger -* /api/invoices: -* post: -* security: -* - bearerAuth: [] -* tags: [Invoices] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Invoices" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Invoices" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await InvoicesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Invoices" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Invoices" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await InvoicesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/invoices/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Invoices" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Invoices" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await InvoicesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/invoices/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Invoices" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await InvoicesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/invoices/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Invoices" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await InvoicesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/invoices: - * get: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Get all invoices - * description: Get all invoices - * responses: - * 200: - * description: Invoices list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Invoices" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await InvoicesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','invoice_number','notes', - - 'subtotal','discount_amount','tax_amount','total_amount','balance_due', - 'issue_date','due_date', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/invoices/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Count all invoices - * description: Count all invoices - * responses: - * 200: - * description: Invoices count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Invoices" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await InvoicesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/invoices/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Find all invoices that match search criteria - * description: Find all invoices that match search criteria - * responses: - * 200: - * description: Invoices list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Invoices" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await InvoicesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/invoices/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Invoices] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Invoices" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await InvoicesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts new file mode 100644 index 0000000..ee8bf0b --- /dev/null +++ b/backend/src/routes/invoices.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/invoices.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'invoices' }); diff --git a/backend/src/routes/message_recipients.js b/backend/src/routes/message_recipients.js deleted file mode 100644 index 237f7ce..0000000 --- a/backend/src/routes/message_recipients.js +++ /dev/null @@ -1,445 +0,0 @@ - -const express = require('express'); - -const Message_recipientsService = require('../services/message_recipients'); -const Message_recipientsDBApi = require('../db/api/message_recipients'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('message_recipients')); - - -/** - * @swagger - * components: - * schemas: - * Message_recipients: - * type: object - * properties: - - * recipient_label: - * type: string - * default: recipient_label - * destination: - * type: string - * default: destination - - - - * - * - */ - -/** - * @swagger - * tags: - * name: Message_recipients - * description: The Message_recipients managing API - */ - -/** -* @swagger -* /api/message_recipients: -* post: -* security: -* - bearerAuth: [] -* tags: [Message_recipients] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Message_recipients" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Message_recipients" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Message_recipientsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Message_recipients" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Message_recipients" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Message_recipientsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/message_recipients/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Message_recipients" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Message_recipients" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Message_recipientsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/message_recipients/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Message_recipients" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Message_recipientsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/message_recipients/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Message_recipients" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Message_recipientsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/message_recipients: - * get: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Get all message_recipients - * description: Get all message_recipients - * responses: - * 200: - * description: Message_recipients list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Message_recipients" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Message_recipientsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','recipient_label','destination', - - - 'delivered_at','read_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/message_recipients/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Count all message_recipients - * description: Count all message_recipients - * responses: - * 200: - * description: Message_recipients count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Message_recipients" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Message_recipientsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/message_recipients/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Find all message_recipients that match search criteria - * description: Find all message_recipients that match search criteria - * responses: - * 200: - * description: Message_recipients list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Message_recipients" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Message_recipientsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/message_recipients/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Message_recipients] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Message_recipients" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Message_recipientsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/message_recipients.ts b/backend/src/routes/message_recipients.ts new file mode 100644 index 0000000..7187952 --- /dev/null +++ b/backend/src/routes/message_recipients.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/message_recipients.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'message_recipients' }); diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js deleted file mode 100644 index 825daa5..0000000 --- a/backend/src/routes/messages.js +++ /dev/null @@ -1,446 +0,0 @@ - -const express = require('express'); - -const MessagesService = require('../services/messages'); -const MessagesDBApi = require('../db/api/messages'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('messages')); - - -/** - * @swagger - * components: - * schemas: - * Messages: - * type: object - * properties: - - * subject: - * type: string - * default: subject - * body: - * type: string - * default: body - - - - * - * - * - */ - -/** - * @swagger - * tags: - * name: Messages - * description: The Messages managing API - */ - -/** -* @swagger -* /api/messages: -* post: -* security: -* - bearerAuth: [] -* tags: [Messages] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Messages" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Messages" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await MessagesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Messages" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Messages" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await MessagesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/messages/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Messages" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Messages" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await MessagesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/messages/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Messages" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await MessagesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/messages/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Messages" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await MessagesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/messages: - * get: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Get all messages - * description: Get all messages - * responses: - * 200: - * description: Messages list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Messages" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await MessagesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','subject','body', - - - 'sent_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/messages/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Count all messages - * description: Count all messages - * responses: - * 200: - * description: Messages count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Messages" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await MessagesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/messages/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Find all messages that match search criteria - * description: Find all messages that match search criteria - * responses: - * 200: - * description: Messages list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Messages" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await MessagesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/messages/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Messages] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Messages" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await MessagesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts new file mode 100644 index 0000000..0a4a6ba --- /dev/null +++ b/backend/src/routes/messages.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/messages.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'messages' }); diff --git a/backend/src/routes/openai.js b/backend/src/routes/openai.js deleted file mode 100644 index 2d47d9f..0000000 --- a/backend/src/routes/openai.js +++ /dev/null @@ -1,328 +0,0 @@ -const express = require('express'); -const db = require('../db/models'); -const wrapAsync = require('../helpers').wrapAsync; -const router = express.Router(); -const sjs = require('sequelize-json-schema'); -const { getWidget, askGpt } = require('../services/openai'); -const { LocalAIApi } = require('../ai/LocalAIApi'); - -const loadRolesModules = () => { - try { - return { - RolesService: require('../services/roles'), - RolesDBApi: require('../db/api/roles'), - }; - } catch (error) { - console.error('Roles modules are missing. Advanced roles are required for this endpoint.', error); - const err = new Error('Roles modules are missing. Advanced roles are required for this endpoint.'); - err.originalError = error; - throw err; - } -}; - -/** - * @swagger - * /api/roles/roles-info/{infoId}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Remove role information by ID - * description: Remove specific role information by ID - * parameters: - * - in: path - * name: infoId - * description: ID of role information to remove - * required: true - * schema: - * type: string - * - in: query - * name: userId - * description: ID of the user - * required: true - * schema: - * type: string - * - in: query - * name: key - * description: Key of the role information to remove - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Role information successfully removed - * content: - * application/json: - * schema: - * type: object - * properties: - * user: - * type: string - * description: The user information - * 400: - * description: Invalid ID or key supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Role not found - * 500: - * description: Some server error - */ - -router.delete( - '/roles-info/:infoId', - wrapAsync(async (req, res) => { - const { RolesService } = loadRolesModules(); - const role = await RolesService.removeRoleInfoById( - req.query.infoId, - req.query.roleId, - req.query.key, - req.currentUser, - ); - - res.status(200).send(role); - }), -); - -/** - * @swagger - * /api/roles/role-info/{roleId}: - * get: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Get role information by key - * description: Get specific role information by key - * parameters: - * - in: path - * name: roleId - * description: ID of role to get information for - * required: true - * schema: - * type: string - * - in: query - * name: key - * description: Key of the role information to retrieve - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Role information successfully received - * content: - * application/json: - * schema: - * type: object - * properties: - * info: - * type: string - * description: The role information - * 400: - * description: Invalid ID or key supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Role not found - * 500: - * description: Some server error - */ - -router.get( - '/info-by-key', - wrapAsync(async (req, res) => { - const { RolesService, RolesDBApi } = loadRolesModules(); - const roleId = req.query.roleId; - const key = req.query.key; - const currentUser = req.currentUser; - let info = await RolesService.getRoleInfoByKey( - key, - roleId, - currentUser, - ); - const role = await RolesDBApi.findBy({ id: roleId }); - if (!role?.role_customization) { - await Promise.all(["pie", "bar"].map(async (e) => { - const schema = await sjs.getSequelizeSchema(db.sequelize, {}); - const payload = { - description: `Create some cool ${e} chart`, - modelDefinition: schema.definitions, - }; - const widgetId = await getWidget(payload, currentUser?.id, roleId); - if (widgetId) { - await RolesService.addRoleInfo( - roleId, - currentUser?.id, - 'widgets', - widgetId, - req.currentUser, - ); - } - })) - info = await RolesService.getRoleInfoByKey( - key, - roleId, - currentUser, - ); - } - res.status(200).send(info); - }), -); - -router.post( - '/create_widget', - wrapAsync(async (req, res) => { - const { RolesService } = loadRolesModules(); - const { description, userId, roleId } = req.body; - - const currentUser = req.currentUser; - const schema = await sjs.getSequelizeSchema(db.sequelize, {}); - const payload = { - description, - modelDefinition: schema.definitions, - }; - - const widgetId = await getWidget(payload, userId, roleId); - - if (widgetId) { - await RolesService.addRoleInfo( - roleId, - userId, - 'widgets', - widgetId, - currentUser, - ); - - return res.status(200).send(widgetId); - } else { - return res.status(400).send(widgetId); - } - }), -); - -/** - * @swagger - * /api/openai/response: - * post: - * security: - * - bearerAuth: [] - * tags: [OpenAI] - * summary: Proxy a Responses API request - * description: Sends the payload to the Flatlogic AI proxy and returns the response. - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * input: - * type: array - * description: List of messages with roles and content. - * items: - * type: object - * properties: - * role: - * type: string - * content: - * type: string - * options: - * type: object - * description: Optional polling controls. - * properties: - * poll_interval: - * type: number - * poll_timeout: - * type: number - * responses: - * 200: - * description: AI response received - * 400: - * description: Invalid request - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 502: - * description: Proxy error - */ -router.post( - '/response', - wrapAsync(async (req, res) => { - const body = req.body || {}; - const options = body.options || {}; - const payload = { ...body }; - delete payload.options; - - const response = await LocalAIApi.createResponse(payload, options); - - if (response.success) { - return res.status(200).send(response); - } - - console.error('AI proxy error:', response); - const status = response.error === 'input_missing' ? 400 : 502; - return res.status(status).send(response); - }), -); - -/** - * @swagger - * /api/openai/ask: - * post: - * security: - * - bearerAuth: [] - * tags: [OpenAI] - * summary: Ask a question to ChatGPT - * description: Send a question through the Flatlogic AI proxy and get a response - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * prompt: - * type: string - * description: The question to ask ChatGPT - * responses: - * 200: - * description: Question successfully answered - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * description: Whether the request was successful - * data: - * type: string - * description: The answer from ChatGPT - * 400: - * description: Invalid request - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Some server error - */ -router.post( - '/ask-gpt', - wrapAsync(async (req, res) => { - const { prompt } = req.body; - if (!prompt) { - return res.status(400).send({ - success: false, - error: 'Prompt is required', - }); - } - - const response = await askGpt(prompt); - - if (response.success) { - return res.status(200).send(response); - } else { - return res.status(500).send(response); - } - }), -); - - -module.exports = router; diff --git a/backend/src/routes/organizationLogin.js b/backend/src/routes/organizationLogin.js deleted file mode 100644 index 718b6c1..0000000 --- a/backend/src/routes/organizationLogin.js +++ /dev/null @@ -1,55 +0,0 @@ - - - -const express = require('express'); - -const OrganizationsDBApi = require('../db/api/organizations'); -const wrapAsync = require('../helpers').wrapAsync; - -const router = express.Router(); - -/** - * @swagger - * /api/organizations: - * get: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Get all organizations - * description: Get all organizations - * responses: - * 200: - * description: Organizations list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ - -router.get( - '/', - wrapAsync(async (req, res) => { - const payload = await OrganizationsDBApi.findAll(req.query); - const simplifiedPayload = payload.rows.map(org => ({ - id: org.id, - name: org.name - })); - res.status(200).send(simplifiedPayload); - - }), -); - - - - -module.exports = router; - - diff --git a/backend/src/routes/organizations.js b/backend/src/routes/organizations.js deleted file mode 100644 index 6f96a1d..0000000 --- a/backend/src/routes/organizations.js +++ /dev/null @@ -1,440 +0,0 @@ - -const express = require('express'); - -const OrganizationsService = require('../services/organizations'); -const OrganizationsDBApi = require('../db/api/organizations'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('organizations')); - - -/** - * @swagger - * components: - * schemas: - * Organizations: - * type: object - * properties: - - * name: - * type: string - * default: name - - - - */ - -/** - * @swagger - * tags: - * name: Organizations - * description: The Organizations managing API - */ - -/** -* @swagger -* /api/organizations: -* post: -* security: -* - bearerAuth: [] -* tags: [Organizations] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Organizations" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Organizations" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await OrganizationsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Organizations" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await OrganizationsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/organizations/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Organizations" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Organizations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await OrganizationsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/organizations/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Organizations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await OrganizationsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/organizations/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await OrganizationsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/organizations: - * get: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Get all organizations - * description: Get all organizations - * responses: - * 200: - * description: Organizations list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await OrganizationsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/organizations/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Count all organizations - * description: Count all organizations - * responses: - * 200: - * description: Organizations count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await OrganizationsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/organizations/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Find all organizations that match search criteria - * description: Find all organizations that match search criteria - * responses: - * 200: - * description: Organizations list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Organizations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await OrganizationsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/organizations/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Organizations] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Organizations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await OrganizationsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts new file mode 100644 index 0000000..3bba64f --- /dev/null +++ b/backend/src/routes/organizations.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/organizations.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'organizations' }); diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js deleted file mode 100644 index 775c641..0000000 --- a/backend/src/routes/payments.js +++ /dev/null @@ -1,450 +0,0 @@ - -const express = require('express'); - -const PaymentsService = require('../services/payments'); -const PaymentsDBApi = require('../db/api/payments'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('payments')); - - -/** - * @swagger - * components: - * schemas: - * Payments: - * type: object - * properties: - - * receipt_number: - * type: string - * default: receipt_number - * reference_code: - * type: string - * default: reference_code - * notes: - * type: string - * default: notes - - - * amount: - * type: integer - * format: int64 - - * - */ - -/** - * @swagger - * tags: - * name: Payments - * description: The Payments managing API - */ - -/** -* @swagger -* /api/payments: -* post: -* security: -* - bearerAuth: [] -* tags: [Payments] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Payments" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Payments" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await PaymentsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Payments" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Payments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await PaymentsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/payments/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Payments" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Payments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await PaymentsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/payments/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Payments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await PaymentsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/payments/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Payments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await PaymentsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/payments: - * get: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Get all payments - * description: Get all payments - * responses: - * 200: - * description: Payments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Payments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await PaymentsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','receipt_number','reference_code','notes', - - 'amount', - 'paid_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/payments/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Count all payments - * description: Count all payments - * responses: - * 200: - * description: Payments count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Payments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await PaymentsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/payments/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Find all payments that match search criteria - * description: Find all payments that match search criteria - * responses: - * 200: - * description: Payments list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Payments" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await PaymentsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/payments/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Payments] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Payments" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await PaymentsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts new file mode 100644 index 0000000..3702605 --- /dev/null +++ b/backend/src/routes/payments.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/payments.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'payments' }); diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js deleted file mode 100644 index b569a78..0000000 --- a/backend/src/routes/permissions.js +++ /dev/null @@ -1,429 +0,0 @@ - -const express = require('express'); - -const PermissionsService = require('../services/permissions'); -const PermissionsDBApi = require('../db/api/permissions'); -const wrapAsync = require('../helpers').wrapAsync; - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('permissions')); - - -/** - * @swagger - * components: - * schemas: - * Permissions: - * type: object - * properties: - - * name: - * type: string - * default: name - - - - */ - -/** - * @swagger - * tags: - * name: Permissions - * description: The Permissions managing API - */ - -/** -* @swagger -* /api/permissions: -* post: -* security: -* - bearerAuth: [] -* tags: [Permissions] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Permissions" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Permissions" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await PermissionsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Permissions" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Permissions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await PermissionsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/permissions/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Permissions" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Permissions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await PermissionsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/permissions/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Permissions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await PermissionsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/permissions/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Permissions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await PermissionsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/permissions: - * get: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Get all permissions - * description: Get all permissions - * responses: - * 200: - * description: Permissions list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Permissions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const currentUser = req.currentUser; - const payload = await PermissionsDBApi.findAll( - req.query, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/permissions/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Count all permissions - * description: Count all permissions - * responses: - * 200: - * description: Permissions count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Permissions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const currentUser = req.currentUser; - const payload = await PermissionsDBApi.findAll( - req.query, - null, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/permissions/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Find all permissions that match search criteria - * description: Find all permissions that match search criteria - * responses: - * 200: - * description: Permissions list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Permissions" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const payload = await PermissionsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/permissions/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Permissions] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Permissions" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await PermissionsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts new file mode 100644 index 0000000..c6c6d54 --- /dev/null +++ b/backend/src/routes/permissions.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as permissionsController from '@/api/controllers/permissions.controller'; +import permissions from '@/middlewares/check-permissions'; + +const router = express.Router(); + +router.use(permissions.checkCrudPermissions('permissions')); + +router.post('/', wrapAsync(permissionsController.create)); +router.post('/bulk-import', wrapAsync(permissionsController.bulkImport)); +router.put('/:id', wrapAsync(permissionsController.update)); +router.delete('/:id', wrapAsync(permissionsController.remove)); +router.post('/deleteByIds', wrapAsync(permissionsController.deleteByIds)); +router.get('/', wrapAsync(permissionsController.list)); +router.get('/count', wrapAsync(permissionsController.count)); +router.get('/autocomplete', wrapAsync(permissionsController.autocomplete)); +router.get('/:id', wrapAsync(permissionsController.findById)); + +export default router; diff --git a/backend/src/routes/personality_quiz_results.js b/backend/src/routes/personality_quiz_results.js deleted file mode 100644 index bbd3c6f..0000000 --- a/backend/src/routes/personality_quiz_results.js +++ /dev/null @@ -1,24 +0,0 @@ -const express = require('express'); -const PersonalityQuizResultsService = require('../services/personality_quiz_results'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/me', wrapAsync(async (req, res) => { - const payload = await PersonalityQuizResultsService.getCurrentUserResult(req.currentUser); - res.status(200).send(payload); -})); - -router.put('/me', wrapAsync(async (req, res) => { - const payload = await PersonalityQuizResultsService.upsertCurrentUserResult(req.body.data, req.currentUser); - res.status(200).send(payload); -})); - -router.get('/distribution', wrapAsync(async (req, res) => { - const payload = await PersonalityQuizResultsService.distribution(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/personality_quiz_results.ts b/backend/src/routes/personality_quiz_results.ts new file mode 100644 index 0000000..6578cfc --- /dev/null +++ b/backend/src/routes/personality_quiz_results.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as personality_quiz_results from '@/api/controllers/personality_quiz_results.controller'; + +const router = express.Router(); + +router.get('/me', wrapAsync(personality_quiz_results.getCurrentUserResult)); +router.put('/me', wrapAsync(personality_quiz_results.upsertCurrentUserResult)); +router.get('/distribution', wrapAsync(personality_quiz_results.distribution)); + +export default router; diff --git a/backend/src/routes/pexels.js b/backend/src/routes/pexels.js deleted file mode 100644 index 0623be0..0000000 --- a/backend/src/routes/pexels.js +++ /dev/null @@ -1,118 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { pexelsKey, pexelsQuery } = require('../config'); -const { - DEFAULT_PEXELS_PAGE, - DEFAULT_PEXELS_PER_PAGE, - PEXELS_FALLBACK_IMAGE, - PEXELS_IMAGE_ORIENTATION, - PEXELS_IMAGE_SEARCH_URL, - PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES, - PEXELS_MULTIPLE_IMAGE_ORIENTATION, - PEXELS_VIDEO_ORIENTATION, - PEXELS_VIDEO_SEARCH_URL, - PICSUM_FALLBACK_URL, - PICSUM_FALLBACK_PHOTOGRAPHER, - UNKNOWN_PHOTOGRAPHER_LABEL, -} = require('../constants/app'); -const fetch = require('node-fetch'); - -const KEY = pexelsKey; - -function buildPexelsSearchUrl(baseUrl, query, orientation) { - const params = new URLSearchParams({ - query, - orientation, - per_page: String(DEFAULT_PEXELS_PER_PAGE), - page: String(DEFAULT_PEXELS_PAGE), - }); - - return `${baseUrl}?${params.toString()}`; -} - -router.get('/image', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - const query = pexelsQuery; - const url = buildPexelsSearchUrl(PEXELS_IMAGE_SEARCH_URL, query, PEXELS_IMAGE_ORIENTATION); - - try { - const response = await fetch(url, { headers }); - const data = await response.json(); - res.status(200).json(data.photos[0]); - } catch (error) { - res.status(200).json({ error: 'Failed to fetch image' }); - } -}); - -router.get('/video', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - const query = pexelsQuery; - const url = buildPexelsSearchUrl(PEXELS_VIDEO_SEARCH_URL, query, PEXELS_VIDEO_ORIENTATION); - - try { - const response = await fetch(url, { headers }); - const data = await response.json(); - res.status(200).json(data.videos[0]); - } catch (error) { - res.status(200).json({ error: 'Failed to fetch video' }); - } -}); - -router.get('/multiple-images', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - - const queries = req.query.queries - ? req.query.queries.split(',') - : PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES; - - const fetchFallbackImage = async () => { - try { - const response = await fetch(PICSUM_FALLBACK_URL); - return { - src: response.url, - photographer: PICSUM_FALLBACK_PHOTOGRAPHER, - photographer_url: PICSUM_FALLBACK_URL, - }; - } catch (error) { - return PEXELS_FALLBACK_IMAGE; - } - }; - const fetchImage = async (query) => { - const url = buildPexelsSearchUrl(PEXELS_IMAGE_SEARCH_URL, query, PEXELS_MULTIPLE_IMAGE_ORIENTATION); - const response = await fetch(url, { headers }); - const data = await response.json(); - return data.photos[0] || null; - }; - - const imagePromises = queries.map((query) => fetchImage(query)); - const imagesResults = await Promise.allSettled(imagePromises); - - const formattedImages = await Promise.all(imagesResults.map(async (result) => { - if (result.status === 'fulfilled' && result.value) { - const image = result.value; - return { - src: image.src?.original || PEXELS_FALLBACK_IMAGE.src, - photographer: image.photographer || PEXELS_FALLBACK_IMAGE.photographer, - photographer_url: image.photographer_url || PEXELS_FALLBACK_IMAGE.photographer_url, - }; - } else { - const fallback = await fetchFallbackImage(); - return { - src: fallback.src || '', - photographer: fallback.photographer || UNKNOWN_PHOTOGRAPHER_LABEL, - photographer_url: fallback.photographer_url || '', - }; - } - })); - - - res.json(formattedImages); -}); - -module.exports = router; diff --git a/backend/src/routes/public_campuses.js b/backend/src/routes/public_campuses.js deleted file mode 100644 index 6e9f86d..0000000 --- a/backend/src/routes/public_campuses.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express'); - -const CampusCatalogService = require('../services/campus_catalog'); -const wrapAsync = require('../helpers').wrapAsync; - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await CampusCatalogService.listActive(); - res.status(200).send(payload); -})); - -module.exports = router; diff --git a/backend/src/routes/public_campuses.ts b/backend/src/routes/public_campuses.ts new file mode 100644 index 0000000..eba6f1a --- /dev/null +++ b/backend/src/routes/public_campuses.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as public_campuses from '@/api/controllers/public_campuses.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(public_campuses.listActive)); + +export default router; diff --git a/backend/src/routes/public_content_catalog.js b/backend/src/routes/public_content_catalog.js deleted file mode 100644 index 78e90db..0000000 --- a/backend/src/routes/public_content_catalog.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require('express'); - -const ContentCatalogService = require('../services/content_catalog'); -const wrapAsync = require('../helpers').wrapAsync; - -const router = express.Router(); - -router.get('/:contentType', wrapAsync(async (req, res) => { - const payload = await ContentCatalogService.findByType(req.params.contentType); - res.status(200).send(payload); -})); - -module.exports = router; diff --git a/backend/src/routes/public_content_catalog.ts b/backend/src/routes/public_content_catalog.ts new file mode 100644 index 0000000..a8bbec9 --- /dev/null +++ b/backend/src/routes/public_content_catalog.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as public_content_catalog from '@/api/controllers/public_content_catalog.controller'; + +const router = express.Router(); + +router.get('/:contentType', wrapAsync(public_content_catalog.findByType)); + +export default router; diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js deleted file mode 100644 index 91ceba8..0000000 --- a/backend/src/routes/roles.js +++ /dev/null @@ -1,438 +0,0 @@ - -const express = require('express'); - -const RolesService = require('../services/roles'); -const RolesDBApi = require('../db/api/roles'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('roles')); - - -/** - * @swagger - * components: - * schemas: - * Roles: - * type: object - * properties: - - * name: - * type: string - * default: name - - - - */ - -/** - * @swagger - * tags: - * name: Roles - * description: The Roles managing API - */ - -/** -* @swagger -* /api/roles: -* post: -* security: -* - bearerAuth: [] -* tags: [Roles] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Roles" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Roles" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await RolesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Roles" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Roles" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await RolesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/roles/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Roles" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Roles" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await RolesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/roles/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Roles" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await RolesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/roles/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Roles" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await RolesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/roles: - * get: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Get all roles - * description: Get all roles - * responses: - * 200: - * description: Roles list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Roles" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await RolesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/roles/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Count all roles - * description: Count all roles - * responses: - * 200: - * description: Roles count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Roles" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await RolesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/roles/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Find all roles that match search criteria - * description: Find all roles that match search criteria - * responses: - * 200: - * description: Roles list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Roles" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - - const payload = await RolesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/roles/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Roles] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Roles" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await RolesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/roles.ts b/backend/src/routes/roles.ts new file mode 100644 index 0000000..21b8207 --- /dev/null +++ b/backend/src/routes/roles.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/roles.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'roles' }); diff --git a/backend/src/routes/safety_quiz_results.js b/backend/src/routes/safety_quiz_results.js deleted file mode 100644 index 4eddcad..0000000 --- a/backend/src/routes/safety_quiz_results.js +++ /dev/null @@ -1,19 +0,0 @@ -const express = require('express'); -const SafetyQuizResultsService = require('../services/safety_quiz_results'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await SafetyQuizResultsService.list(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.post('/', wrapAsync(async (req, res) => { - const payload = await SafetyQuizResultsService.create(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/safety_quiz_results.ts b/backend/src/routes/safety_quiz_results.ts new file mode 100644 index 0000000..f6ed801 --- /dev/null +++ b/backend/src/routes/safety_quiz_results.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as safety_quiz_results from '@/api/controllers/safety_quiz_results.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(safety_quiz_results.list)); +router.post('/', wrapAsync(safety_quiz_results.create)); + +export default router; diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js deleted file mode 100644 index 25da9e0..0000000 --- a/backend/src/routes/search.js +++ /dev/null @@ -1,56 +0,0 @@ -const express = require('express'); -const SearchService = require('../services/search'); - -const config = require('../config'); - - -const router = express.Router(); - -const { checkCrudPermissions } = require('../middlewares/check-permissions'); -router.use(checkCrudPermissions('search')); - -/** - * @swagger - * path: - * /api/search: - * post: - * summary: Search - * description: Search results across multiple tables - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * searchQuery: - * type: string - * required: - * - searchQuery - * responses: - * 200: - * description: Successful request - * 400: - * description: Invalid request - * 500: - * description: Internal server error - */ - -router.post('/', async (req, res) => { - const { searchQuery , organizationId} = req.body; - - const globalAccess = req.currentUser.app_role.globalAccess; - - if (!searchQuery) { - return res.status(400).json({ error: 'Please enter a search query' }); - } - - try { - const foundMatches = await SearchService.search(searchQuery, req.currentUser , organizationId, globalAccess,); - res.json(foundMatches); - } catch (error) { - console.error('Internal Server Error', error); - res.status(500).json({ error: 'Internal Server Error' }); - } - }); - -module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts new file mode 100644 index 0000000..89dcfdb --- /dev/null +++ b/backend/src/routes/search.ts @@ -0,0 +1,12 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as search from '@/api/controllers/search.controller'; +import permissions from '@/middlewares/check-permissions'; + +const router = express.Router(); + +router.use(permissions.checkCrudPermissions('search')); + +router.post('/', wrapAsync(search.search)); + +export default router; diff --git a/backend/src/routes/sql.js b/backend/src/routes/sql.js deleted file mode 100644 index b844f07..0000000 --- a/backend/src/routes/sql.js +++ /dev/null @@ -1,61 +0,0 @@ -const express = require('express'); -const db = require('../db/models'); -const wrapAsync = require('../helpers').wrapAsync; - -const router = express.Router(); - -/** - * @swagger - * /api/sql: - * post: - * security: - * - bearerAuth: [] - * summary: Execute a SELECT-only SQL query - * description: Executes a read-only SQL query and returns rows. - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * sql: - * type: string - * required: - * - sql - * responses: - * 200: - * description: Query result - * 400: - * description: Invalid SQL - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 500: - * description: Internal server error - */ -router.post( - '/', - wrapAsync(async (req, res) => { - const { sql } = req.body; - if (typeof sql !== 'string' || !sql.trim()) { - return res.status(400).json({ error: 'SQL is required' }); - } - - const normalized = sql.trim().replace(/;+\s*$/, ''); - if (!/^select\b/i.test(normalized)) { - return res.status(400).json({ error: 'Only SELECT statements are allowed' }); - } - - if (normalized.includes(';')) { - return res.status(400).json({ error: 'Only a single SELECT statement is allowed' }); - } - - const rows = await db.sequelize.query(normalized, { - type: db.Sequelize.QueryTypes.SELECT, - }); - - return res.status(200).json({ rows }); - }), -); - -module.exports = router; diff --git a/backend/src/routes/staff.js b/backend/src/routes/staff.js deleted file mode 100644 index 5e08e13..0000000 --- a/backend/src/routes/staff.js +++ /dev/null @@ -1,445 +0,0 @@ - -const express = require('express'); - -const StaffService = require('../services/staff'); -const StaffDBApi = require('../db/api/staff'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('staff')); - - -/** - * @swagger - * components: - * schemas: - * Staff: - * type: object - * properties: - - * employee_number: - * type: string - * default: employee_number - * job_title: - * type: string - * default: job_title - - - - * - * - */ - -/** - * @swagger - * tags: - * name: Staff - * description: The Staff managing API - */ - -/** -* @swagger -* /api/staff: -* post: -* security: -* - bearerAuth: [] -* tags: [Staff] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Staff" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Staff" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await StaffService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Staff" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Staff" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await StaffService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/staff/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Staff" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Staff" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await StaffService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/staff/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Staff" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await StaffService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/staff/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Staff" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await StaffService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/staff: - * get: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Get all staff - * description: Get all staff - * responses: - * 200: - * description: Staff list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Staff" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await StaffDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','employee_number','job_title', - - - 'hire_date', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/staff/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Count all staff - * description: Count all staff - * responses: - * 200: - * description: Staff count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Staff" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await StaffDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/staff/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Find all staff that match search criteria - * description: Find all staff that match search criteria - * responses: - * 200: - * description: Staff list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Staff" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await StaffDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/staff/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Staff] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Staff" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await StaffDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/staff.ts b/backend/src/routes/staff.ts new file mode 100644 index 0000000..9b34874 --- /dev/null +++ b/backend/src/routes/staff.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/staff.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'staff' }); diff --git a/backend/src/routes/staff_attendance.js b/backend/src/routes/staff_attendance.js deleted file mode 100644 index 28b5440..0000000 --- a/backend/src/routes/staff_attendance.js +++ /dev/null @@ -1,19 +0,0 @@ -const express = require('express'); -const StaffAttendanceService = require('../services/staff_attendance'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/records', wrapAsync(async (req, res) => { - const payload = await StaffAttendanceService.listRecords(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.get('/summary', wrapAsync(async (req, res) => { - const payload = await StaffAttendanceService.summary(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/staff_attendance.ts b/backend/src/routes/staff_attendance.ts new file mode 100644 index 0000000..fea1012 --- /dev/null +++ b/backend/src/routes/staff_attendance.ts @@ -0,0 +1,10 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as staff_attendance from '@/api/controllers/staff_attendance.controller'; + +const router = express.Router(); + +router.get('/records', wrapAsync(staff_attendance.listRecords)); +router.get('/summary', wrapAsync(staff_attendance.summary)); + +export default router; diff --git a/backend/src/routes/students.js b/backend/src/routes/students.js deleted file mode 100644 index fafb268..0000000 --- a/backend/src/routes/students.js +++ /dev/null @@ -1,457 +0,0 @@ - -const express = require('express'); - -const StudentsService = require('../services/students'); -const StudentsDBApi = require('../db/api/students'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('students')); - - -/** - * @swagger - * components: - * schemas: - * Students: - * type: object - * properties: - - * student_number: - * type: string - * default: student_number - * first_name: - * type: string - * default: first_name - * last_name: - * type: string - * default: last_name - * email: - * type: string - * default: email - * phone: - * type: string - * default: phone - * address: - * type: string - * default: address - - - - * - * - */ - -/** - * @swagger - * tags: - * name: Students - * description: The Students managing API - */ - -/** -* @swagger -* /api/students: -* post: -* security: -* - bearerAuth: [] -* tags: [Students] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Students" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Students" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await StudentsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Students" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Students" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await StudentsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/students/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Students" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Students" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await StudentsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/students/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Students" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await StudentsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/students/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Students" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await StudentsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/students: - * get: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Get all students - * description: Get all students - * responses: - * 200: - * description: Students list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Students" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await StudentsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','student_number','first_name','last_name','email','phone','address', - - - 'date_of_birth','enrollment_date', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/students/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Count all students - * description: Count all students - * responses: - * 200: - * description: Students count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Students" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await StudentsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/students/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Find all students that match search criteria - * description: Find all students that match search criteria - * responses: - * 200: - * description: Students list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Students" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await StudentsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/students/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Students] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Students" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await StudentsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts new file mode 100644 index 0000000..9a9b078 --- /dev/null +++ b/backend/src/routes/students.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/students.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'students' }); diff --git a/backend/src/routes/subjects.js b/backend/src/routes/subjects.js deleted file mode 100644 index c6d77f0..0000000 --- a/backend/src/routes/subjects.js +++ /dev/null @@ -1,446 +0,0 @@ - -const express = require('express'); - -const SubjectsService = require('../services/subjects'); -const SubjectsDBApi = require('../db/api/subjects'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('subjects')); - - -/** - * @swagger - * components: - * schemas: - * Subjects: - * type: object - * properties: - - * name: - * type: string - * default: name - * code: - * type: string - * default: code - * description: - * type: string - * default: description - - - - */ - -/** - * @swagger - * tags: - * name: Subjects - * description: The Subjects managing API - */ - -/** -* @swagger -* /api/subjects: -* post: -* security: -* - bearerAuth: [] -* tags: [Subjects] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Subjects" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Subjects" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await SubjectsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Subjects" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await SubjectsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/subjects/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Subjects" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await SubjectsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/subjects/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await SubjectsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/subjects/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await SubjectsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/subjects: - * get: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Get all subjects - * description: Get all subjects - * responses: - * 200: - * description: Subjects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await SubjectsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name','code','description', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/subjects/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Count all subjects - * description: Count all subjects - * responses: - * 200: - * description: Subjects count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await SubjectsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/subjects/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Find all subjects that match search criteria - * description: Find all subjects that match search criteria - * responses: - * 200: - * description: Subjects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Subjects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await SubjectsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/subjects/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Subjects] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Subjects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await SubjectsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/subjects.ts b/backend/src/routes/subjects.ts new file mode 100644 index 0000000..8a88c89 --- /dev/null +++ b/backend/src/routes/subjects.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/subjects.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'subjects' }); diff --git a/backend/src/routes/timetable_periods.js b/backend/src/routes/timetable_periods.js deleted file mode 100644 index 5b2b762..0000000 --- a/backend/src/routes/timetable_periods.js +++ /dev/null @@ -1,441 +0,0 @@ - -const express = require('express'); - -const Timetable_periodsService = require('../services/timetable_periods'); -const Timetable_periodsDBApi = require('../db/api/timetable_periods'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('timetable_periods')); - - -/** - * @swagger - * components: - * schemas: - * Timetable_periods: - * type: object - * properties: - - * room: - * type: string - * default: room - - - - * - */ - -/** - * @swagger - * tags: - * name: Timetable_periods - * description: The Timetable_periods managing API - */ - -/** -* @swagger -* /api/timetable_periods: -* post: -* security: -* - bearerAuth: [] -* tags: [Timetable_periods] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Timetable_periods" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Timetable_periods" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Timetable_periodsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Timetable_periods" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetable_periods" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await Timetable_periodsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetable_periods/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Timetable_periods" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetable_periods" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await Timetable_periodsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetable_periods/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetable_periods" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await Timetable_periodsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetable_periods/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetable_periods" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await Timetable_periodsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/timetable_periods: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Get all timetable_periods - * description: Get all timetable_periods - * responses: - * 200: - * description: Timetable_periods list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetable_periods" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Timetable_periodsDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','room', - - - 'starts_at','ends_at', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/timetable_periods/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Count all timetable_periods - * description: Count all timetable_periods - * responses: - * 200: - * description: Timetable_periods count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetable_periods" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await Timetable_periodsDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetable_periods/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Find all timetable_periods that match search criteria - * description: Find all timetable_periods that match search criteria - * responses: - * 200: - * description: Timetable_periods list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetable_periods" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await Timetable_periodsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/timetable_periods/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetable_periods] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetable_periods" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await Timetable_periodsDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/timetable_periods.ts b/backend/src/routes/timetable_periods.ts new file mode 100644 index 0000000..b6a4712 --- /dev/null +++ b/backend/src/routes/timetable_periods.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/timetable_periods.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'timetable_periods' }); diff --git a/backend/src/routes/timetables.js b/backend/src/routes/timetables.js deleted file mode 100644 index 7b6cba1..0000000 --- a/backend/src/routes/timetables.js +++ /dev/null @@ -1,441 +0,0 @@ - -const express = require('express'); - -const TimetablesService = require('../services/timetables'); -const TimetablesDBApi = require('../db/api/timetables'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('timetables')); - - -/** - * @swagger - * components: - * schemas: - * Timetables: - * type: object - * properties: - - * name: - * type: string - * default: name - - - - * - */ - -/** - * @swagger - * tags: - * name: Timetables - * description: The Timetables managing API - */ - -/** -* @swagger -* /api/timetables: -* post: -* security: -* - bearerAuth: [] -* tags: [Timetables] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Timetables" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Timetables" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await TimetablesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Timetables" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetables" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await TimetablesService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetables/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Timetables" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetables" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await TimetablesService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetables/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetables" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await TimetablesService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetables/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetables" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await TimetablesService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/timetables: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Get all timetables - * description: Get all timetables - * responses: - * 200: - * description: Timetables list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetables" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await TimetablesDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','name', - - - 'effective_from','effective_to', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/timetables/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Count all timetables - * description: Count all timetables - * responses: - * 200: - * description: Timetables count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetables" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await TimetablesDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/timetables/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Find all timetables that match search criteria - * description: Find all timetables that match search criteria - * responses: - * 200: - * description: Timetables list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Timetables" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await TimetablesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/timetables/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Timetables] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Timetables" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await TimetablesDBApi.findBy( - { id: req.params.id }, - ); - - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/timetables.ts b/backend/src/routes/timetables.ts new file mode 100644 index 0000000..7992099 --- /dev/null +++ b/backend/src/routes/timetables.ts @@ -0,0 +1,4 @@ +import controller from '@/api/controllers/timetables.controller'; +import { createCrudRouter } from '@/api/http/crud-router'; + +export default createCrudRouter(controller, { permission: 'timetables' }); diff --git a/backend/src/routes/user_progress.js b/backend/src/routes/user_progress.js deleted file mode 100644 index b619b39..0000000 --- a/backend/src/routes/user_progress.js +++ /dev/null @@ -1,24 +0,0 @@ -const express = require('express'); -const UserProgressService = require('../services/user_progress'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await UserProgressService.list(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.post('/', wrapAsync(async (req, res) => { - const payload = await UserProgressService.upsert(req.body.data, req.currentUser); - res.status(200).send(payload); -})); - -router.delete('/by-item', wrapAsync(async (req, res) => { - const payload = await UserProgressService.removeByItem(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/user_progress.ts b/backend/src/routes/user_progress.ts new file mode 100644 index 0000000..0f9693d --- /dev/null +++ b/backend/src/routes/user_progress.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as user_progress from '@/api/controllers/user_progress.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(user_progress.list)); +router.post('/', wrapAsync(user_progress.upsert)); +router.delete('/by-item', wrapAsync(user_progress.removeByItem)); + +export default router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js deleted file mode 100644 index 69f227e..0000000 --- a/backend/src/routes/users.js +++ /dev/null @@ -1,451 +0,0 @@ - -const express = require('express'); - -const UsersService = require('../services/users'); -const UsersDBApi = require('../db/api/users'); -const wrapAsync = require('../helpers').wrapAsync; - -const config = require('../config'); - - -const router = express.Router(); - -const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('users')); - - -/** - * @swagger - * components: - * schemas: - * Users: - * type: object - * properties: - - * firstName: - * type: string - * default: firstName - * lastName: - * type: string - * default: lastName - * phoneNumber: - * type: string - * default: phoneNumber - * email: - * type: string - * default: email - - - - */ - -/** - * @swagger - * tags: - * name: Users - * description: The Users managing API - */ - -/** -* @swagger -* /api/users: -* post: -* security: -* - bearerAuth: [] -* tags: [Users] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Users" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Users" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ -router.post('/', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await UsersService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Users" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post('/bulk-import', wrapAsync(async (req, res) => { - const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await UsersService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/users/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Users" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put('/:id', wrapAsync(async (req, res) => { - await UsersService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/users/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete('/:id', wrapAsync(async (req, res) => { - await UsersService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/users/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post('/deleteByIds', wrapAsync(async (req, res) => { - await UsersService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - })); - -/** - * @swagger - * /api/users: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Get all users - * description: Get all users - * responses: - * 200: - * description: Users list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ -router.get('/', wrapAsync(async (req, res) => { - const filetype = req.query.filetype - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await UsersDBApi.findAll( - req.query, globalAccess, { currentUser } - ); - if (filetype && filetype === 'csv') { - const fields = ['id','firstName','lastName','phoneNumber','email', - - - - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv) - - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - -})); - -/** - * @swagger - * /api/users/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Count all users - * description: Count all users - * responses: - * 200: - * description: Users count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/count', wrapAsync(async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const currentUser = req.currentUser; - const payload = await UsersDBApi.findAll( - req.query, - globalAccess, - { countOnly: true, currentUser } - ); - - res.status(200).send(payload); -})); - -/** - * @swagger - * /api/users/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Find all users that match search criteria - * description: Find all users that match search criteria - * responses: - * 200: - * description: Users list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - - const globalAccess = req.currentUser.app_role.globalAccess; - - const organizationId = req.currentUser.organization?.id - - - const payload = await UsersDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - globalAccess, organizationId, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/users/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get('/:id', wrapAsync(async (req, res) => { - const payload = await UsersDBApi.findBy( - { id: req.params.id }, - ); - - - delete payload.password; - - - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..91a91e0 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as users from '@/api/controllers/users.controller'; +import permissions from '@/middlewares/check-permissions'; + +const router = express.Router(); + +router.use(permissions.checkCrudPermissions('users')); + +router.post('/', wrapAsync(users.create)); +router.post('/bulk-import', wrapAsync(users.bulkImport)); +router.put('/:id', wrapAsync(users.update)); +router.delete('/:id', wrapAsync(users.remove)); +router.post('/deleteByIds', wrapAsync(users.deleteByIds)); +router.get('/', wrapAsync(users.list)); +router.get('/count', wrapAsync(users.count)); +router.get('/autocomplete', wrapAsync(users.autocomplete)); +router.get('/:id', wrapAsync(users.findById)); + +export default router; diff --git a/backend/src/routes/walkthrough_checkins.js b/backend/src/routes/walkthrough_checkins.js deleted file mode 100644 index 7717fa1..0000000 --- a/backend/src/routes/walkthrough_checkins.js +++ /dev/null @@ -1,24 +0,0 @@ -const express = require('express'); -const WalkthroughCheckinsService = require('../services/walkthrough_checkins'); -const { wrapAsync } = require('../helpers'); - -const router = express.Router(); - -router.get('/', wrapAsync(async (req, res) => { - const payload = await WalkthroughCheckinsService.list(req.query, req.currentUser); - res.status(200).send(payload); -})); - -router.post('/', wrapAsync(async (req, res) => { - const payload = await WalkthroughCheckinsService.create(req.body.data, req.currentUser); - res.status(201).send(payload); -})); - -router.delete('/:id', wrapAsync(async (req, res) => { - const payload = await WalkthroughCheckinsService.remove(req.params.id, req.currentUser); - res.status(200).send(payload); -})); - -router.use('/', require('../helpers').commonErrorHandler); - -module.exports = router; diff --git a/backend/src/routes/walkthrough_checkins.ts b/backend/src/routes/walkthrough_checkins.ts new file mode 100644 index 0000000..fcde224 --- /dev/null +++ b/backend/src/routes/walkthrough_checkins.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import * as walkthrough from '@/api/controllers/walkthrough_checkins.controller'; + +const router = express.Router(); + +router.get('/', wrapAsync(walkthrough.list)); +router.post('/', wrapAsync(walkthrough.create)); +router.delete('/:id', wrapAsync(walkthrough.remove)); + +export default router; diff --git a/backend/src/services/academic_years.js b/backend/src/services/academic_years.js deleted file mode 100644 index af125fb..0000000 --- a/backend/src/services/academic_years.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Academic_yearsDBApi = require('../db/api/academic_years'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Academic_yearsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Academic_yearsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Academic_yearsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let academic_years = await Academic_yearsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!academic_years) { - throw new ValidationError( - 'academic_yearsNotFound', - ); - } - - const updatedAcademic_years = await Academic_yearsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedAcademic_years; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Academic_yearsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Academic_yearsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/academic_years.ts b/backend/src/services/academic_years.ts new file mode 100644 index 0000000..0c095ab --- /dev/null +++ b/backend/src/services/academic_years.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/academic_years'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'academic_yearsNotFound' }); diff --git a/backend/src/services/assessment_results.js b/backend/src/services/assessment_results.js deleted file mode 100644 index 07af358..0000000 --- a/backend/src/services/assessment_results.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Assessment_resultsDBApi = require('../db/api/assessment_results'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Assessment_resultsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Assessment_resultsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Assessment_resultsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let assessment_results = await Assessment_resultsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!assessment_results) { - throw new ValidationError( - 'assessment_resultsNotFound', - ); - } - - const updatedAssessment_results = await Assessment_resultsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedAssessment_results; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Assessment_resultsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Assessment_resultsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/assessment_results.ts b/backend/src/services/assessment_results.ts new file mode 100644 index 0000000..0e148c6 --- /dev/null +++ b/backend/src/services/assessment_results.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/assessment_results'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'assessment_resultsNotFound' }); diff --git a/backend/src/services/assessments.js b/backend/src/services/assessments.js deleted file mode 100644 index ff0c5c1..0000000 --- a/backend/src/services/assessments.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const AssessmentsDBApi = require('../db/api/assessments'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class AssessmentsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await AssessmentsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await AssessmentsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let assessments = await AssessmentsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!assessments) { - throw new ValidationError( - 'assessmentsNotFound', - ); - } - - const updatedAssessments = await AssessmentsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedAssessments; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await AssessmentsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await AssessmentsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/assessments.ts b/backend/src/services/assessments.ts new file mode 100644 index 0000000..61dbc17 --- /dev/null +++ b/backend/src/services/assessments.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/assessments'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'assessmentsNotFound' }); diff --git a/backend/src/services/attendance_records.js b/backend/src/services/attendance_records.js deleted file mode 100644 index 9baee79..0000000 --- a/backend/src/services/attendance_records.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Attendance_recordsDBApi = require('../db/api/attendance_records'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Attendance_recordsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Attendance_recordsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Attendance_recordsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let attendance_records = await Attendance_recordsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!attendance_records) { - throw new ValidationError( - 'attendance_recordsNotFound', - ); - } - - const updatedAttendance_records = await Attendance_recordsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedAttendance_records; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Attendance_recordsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Attendance_recordsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/attendance_records.ts b/backend/src/services/attendance_records.ts new file mode 100644 index 0000000..086642e --- /dev/null +++ b/backend/src/services/attendance_records.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/attendance_records'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'attendance_recordsNotFound' }); diff --git a/backend/src/services/attendance_sessions.js b/backend/src/services/attendance_sessions.js deleted file mode 100644 index 48fd7d0..0000000 --- a/backend/src/services/attendance_sessions.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Attendance_sessionsDBApi = require('../db/api/attendance_sessions'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Attendance_sessionsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Attendance_sessionsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Attendance_sessionsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let attendance_sessions = await Attendance_sessionsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!attendance_sessions) { - throw new ValidationError( - 'attendance_sessionsNotFound', - ); - } - - const updatedAttendance_sessions = await Attendance_sessionsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedAttendance_sessions; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Attendance_sessionsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Attendance_sessionsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/attendance_sessions.ts b/backend/src/services/attendance_sessions.ts new file mode 100644 index 0000000..7a3a019 --- /dev/null +++ b/backend/src/services/attendance_sessions.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/attendance_sessions'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'attendance_sessionsNotFound' }); diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js deleted file mode 100644 index 47d911b..0000000 --- a/backend/src/services/auth.js +++ /dev/null @@ -1,592 +0,0 @@ -const UsersDBApi = require('../db/api/users'); -const AuthRefreshTokensDBApi = require('../db/api/auth_refresh_tokens'); -const ValidationError = require('./notifications/errors/validation'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const bcrypt = require('bcrypt'); -const crypto = require('crypto'); -const EmailAddressVerificationEmail = require('./email/list/addressVerification'); -const InvitationEmail = require("./email/list/invitation"); -const PasswordResetEmail = require('./email/list/passwordReset'); -const EmailSender = require('./email'); -const config = require('../config'); -const helpers = require('../helpers'); -const db = require('../db/models'); -const { - GENERATED_ROLE_TO_PRODUCT_ROLE, - PRODUCT_ROLE_VALUES, - STAFF_TYPE_TO_PRODUCT_ROLE, -} = require('../constants/roles'); - -function toPlainRecord(record) { - if (!record) { - return null; - } - - if (typeof record.get === 'function') { - return record.get({plain: true}); - } - - return record; -} - -function toRoleDto(role) { - const plainRole = toPlainRecord(role); - - if (!plainRole) { - return null; - } - - return { - id: plainRole.id, - name: plainRole.name, - globalAccess: plainRole.globalAccess, - }; -} - -function toOrganizationDto(organization) { - const plainOrganization = toPlainRecord(organization); - - if (!plainOrganization) { - return null; - } - - return { - id: plainOrganization.id, - name: plainOrganization.name, - }; -} - -function toCampusDto(campus) { - const plainCampus = toPlainRecord(campus); - - if (!plainCampus) { - return null; - } - - return { - id: plainCampus.id, - name: plainCampus.name, - code: plainCampus.code, - }; -} - -function toStaffProfileDto(staffProfile) { - const plainStaffProfile = toPlainRecord(staffProfile); - - if (!plainStaffProfile) { - return null; - } - - return { - id: plainStaffProfile.id, - employee_number: plainStaffProfile.employee_number, - job_title: plainStaffProfile.job_title, - staff_type: plainStaffProfile.staff_type, - status: plainStaffProfile.status, - organizationId: plainStaffProfile.organizationId, - campusId: plainStaffProfile.campusId, - userId: plainStaffProfile.userId, - }; -} - -function getPermissionNames(user) { - const permissions = [ - ...(user.app_role_permissions || []), - ...(user.custom_permissions || []), - ]; - - return [...new Set( - permissions - .map((permission) => toPlainRecord(permission)) - .filter((permission) => permission && permission.name) - .map((permission) => permission.name), - )]; -} - -function getProductRole(role, staffProfile) { - const roleDto = toRoleDto(role); - const staffProfileDto = toStaffProfileDto(staffProfile); - - if (roleDto && GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]) { - return GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]; - } - - if ( - staffProfileDto - && staffProfileDto.staff_type - && STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type] - ) { - return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type]; - } - - return PRODUCT_ROLE_VALUES.TEACHER; -} - -function getTokenPayload(user) { - return { - user: { - id: user.id, - email: user.email - } - }; -} - -function getRequestUserAgent(req) { - return req && req.headers ? req.headers['user-agent'] || null : null; -} - -function getRequestIp(req) { - return req ? req.ip || req.connection?.remoteAddress || null : null; -} - -function generateOpaqueRefreshToken() { - return crypto.randomBytes(config.auth.refreshTokenBytes).toString('base64url'); -} - -function hashRefreshToken(token) { - return crypto - .createHash(config.auth.refreshTokenHashAlgorithm) - .update(token) - .digest('hex'); -} - -function getRefreshTokenExpiry() { - return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs); -} - -class Auth { - static async currentUserProfile(currentUser) { - if (!currentUser || !currentUser.id) { - throw new ForbiddenError(); - } - - const user = await UsersDBApi.findBy({id: currentUser.id}); - - if (!user) { - throw new ForbiddenError(); - } - - const staffProfile = Array.isArray(user.staff_user) && user.staff_user.length > 0 - ? user.staff_user[0] - : null; - const campus = staffProfile && typeof staffProfile.getCampus === 'function' - ? await staffProfile.getCampus() - : null; - const staffProfileDto = toStaffProfileDto(staffProfile); - const campusDto = toCampusDto(campus); - - return { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - phoneNumber: user.phoneNumber, - organizationsId: user.organizationsId, - organizations: toOrganizationDto(user.organizations), - app_role: toRoleDto(user.app_role), - productRole: getProductRole(user.app_role, staffProfile), - staffProfile: staffProfileDto, - campus: campusDto, - campusId: campusDto ? campusDto.id : staffProfileDto?.campusId || null, - permissions: getPermissionNames(user), - }; - } - - static async createSession(user, req, options = {}) { - const refreshToken = generateOpaqueRefreshToken(); - const familyId = options.familyId || crypto.randomUUID(); - const tokenRecord = await AuthRefreshTokensDBApi.create( - { - userId: user.id, - organizationId: user.organizationsId || null, - tokenHash: hashRefreshToken(refreshToken), - familyId, - previousTokenId: options.previousTokenId || null, - userAgent: getRequestUserAgent(req), - ipAddress: getRequestIp(req), - expiresAt: getRefreshTokenExpiry(), - }, - options, - ); - - return { - accessToken: helpers.jwtSign(getTokenPayload(user)), - refreshToken, - refreshTokenRecord: tokenRecord, - user, - }; - } - - static async refreshSession(refreshToken, req) { - if (!refreshToken) { - throw new ForbiddenError(); - } - - const tokenHash = hashRefreshToken(refreshToken); - const transaction = await db.sequelize.transaction(); - - try { - const existingToken = await AuthRefreshTokensDBApi.findByHash( - tokenHash, - { transaction }, - ); - - if (!existingToken) { - throw new ForbiddenError(); - } - - if (existingToken.revokedAt) { - await AuthRefreshTokensDBApi.revokeFamily( - existingToken.familyId, - { transaction }, - ); - await transaction.commit(); - throw new ForbiddenError(); - } - - if (new Date(existingToken.expiresAt).getTime() <= Date.now()) { - await AuthRefreshTokensDBApi.revoke( - existingToken.id, - null, - { transaction }, - ); - await transaction.commit(); - throw new ForbiddenError(); - } - - const user = await UsersDBApi.findBy({id: existingToken.userId}); - - if (!user || user.disabled) { - await AuthRefreshTokensDBApi.revokeFamily( - existingToken.familyId, - { transaction }, - ); - await transaction.commit(); - throw new ForbiddenError(); - } - - const nextSession = await this.createSession( - user, - req, - { - familyId: existingToken.familyId, - previousTokenId: existingToken.id, - transaction, - }, - ); - - await AuthRefreshTokensDBApi.revoke( - existingToken.id, - nextSession.refreshTokenRecord.id, - { transaction }, - ); - - await transaction.commit(); - return nextSession; - } catch (error) { - if (!transaction.finished) { - await transaction.rollback(); - } - throw error; - } - } - - static async revokeSession(refreshToken) { - if (!refreshToken) { - return; - } - - const tokenRecord = await AuthRefreshTokensDBApi.findByHash( - hashRefreshToken(refreshToken), - ); - - if (!tokenRecord || tokenRecord.revokedAt) { - return; - } - - await AuthRefreshTokensDBApi.revoke(tokenRecord.id); - } - - static async signup(email, password, organizationId, options = {}, host) { - const user = await UsersDBApi.findBy({email}); - - const hashedPassword = await bcrypt.hash( - password, - config.bcrypt.saltRounds, - ); - - if (user) { - if (user.authenticationUid) { - throw new ValidationError( - 'auth.emailAlreadyInUse', - ); - } - - if (user.disabled) { - throw new ValidationError( - 'auth.userDisabled', - ); - } - - await UsersDBApi.updatePassword( - user.id, - hashedPassword, - options, - ); - - if (EmailSender.isConfigured) { - await this.sendEmailAddressVerificationEmail( - user.email, - host, - ); - } - - return { - user, - }; - } - - const newUser = await UsersDBApi.createFromAuth( - { - firstName: email.split('@')[0], - password: hashedPassword, - email: email, - - organizationId: organizationId, - - }, - options, - ); - - if (EmailSender.isConfigured) { - await this.sendEmailAddressVerificationEmail( - newUser.email, - host, - ); - } - - return { - user: newUser, - }; - } - - static async signin(email, password) { - const user = await UsersDBApi.findBy({email}); - - if (!user) { - throw new ValidationError( - 'auth.userNotFound', - ); - } - - if (user.disabled) { - throw new ValidationError( - 'auth.userDisabled', - ); - } - - if (!user.password) { - throw new ValidationError( - 'auth.wrongPassword', - ); - } - - if (!EmailSender.isConfigured) { - user.emailVerified = true; - } - - if (!user.emailVerified) { - throw new ValidationError( - 'auth.userNotVerified', - ); - } - - const passwordsMatch = await bcrypt.compare( - password, - user.password, - ); - - if (!passwordsMatch) { - throw new ValidationError( - 'auth.wrongPassword', - ); - } - - return { - user, - }; - } - - static async sendEmailAddressVerificationEmail( - email, - host, - ) { - - - let link; - try { - const token = await UsersDBApi.generateEmailVerificationToken( - email, - ); - link = `${host}/verify-email?token=${token}`; - } catch (error) { - console.error(error); - throw new ValidationError( - 'auth.emailAddressVerificationEmail.error', - ); - } - - const emailAddressVerificationEmail = new EmailAddressVerificationEmail( - email, - link, - ); - - return new EmailSender( - emailAddressVerificationEmail, - ).send(); - } - - static async sendPasswordResetEmail(email, type = 'register', host) { - - - let link; - - try { - const token = await UsersDBApi.generatePasswordResetToken( - email, - ); - link = `${host}/password-reset?token=${token}`; - } catch (error) { - console.error(error); - throw new ValidationError( - 'auth.passwordReset.error', - ); - } - - let passwordResetEmail; - if (type === 'register') { - passwordResetEmail = new PasswordResetEmail( - email, - link, - ); - } - if (type === 'invitation') { - passwordResetEmail = new InvitationEmail( - email, - link, - ); - } - - return new EmailSender(passwordResetEmail).send(); - } - - static async verifyEmail(token, options = {}) { - const user = await UsersDBApi.findByEmailVerificationToken( - token, - options, - ); - - if (!user) { - throw new ValidationError( - 'auth.emailAddressVerificationEmail.invalidToken', - ); - } - - return UsersDBApi.markEmailVerified( - user.id, - options, - ); - } - - static async passwordUpdate(currentPassword, newPassword, options) { - const currentUser = options.currentUser || null; - if (!currentUser) { - throw new ForbiddenError(); - } - - const currentPasswordMatch = await bcrypt.compare( - currentPassword, - currentUser.password, - ); - - if (!currentPasswordMatch) { - throw new ValidationError( - 'auth.wrongPassword' - ) - } - - const newPasswordMatch = await bcrypt.compare( - newPassword, - currentUser.password, - ); - - if (newPasswordMatch) { - throw new ValidationError( - 'auth.passwordUpdate.samePassword' - ) - } - - const hashedPassword = await bcrypt.hash( - newPassword, - config.bcrypt.saltRounds, - ); - - return UsersDBApi.updatePassword( - currentUser.id, - hashedPassword, - options, - ); - } - - static async passwordReset( - token, - password, - options = {}, - ) { - const user = await UsersDBApi.findByPasswordResetToken( - token, - options, - ); - - if (!user) { - throw new ValidationError( - 'auth.passwordReset.invalidToken', - ); - } - - const hashedPassword = await bcrypt.hash( - password, - config.bcrypt.saltRounds, - ); - - return UsersDBApi.updatePassword( - user.id, - hashedPassword, - options, - ); - } - - static async updateProfile(data, currentUser) { - let transaction = await db.sequelize.transaction(); - - try { - await UsersDBApi.findBy( - {id: currentUser.id}, - {transaction}, - ); - - await UsersDBApi.update(currentUser.id, data, null, { - currentUser, - transaction - }); - - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -} - -module.exports = Auth; diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts new file mode 100644 index 0000000..c276016 --- /dev/null +++ b/backend/src/services/auth.ts @@ -0,0 +1,531 @@ +import { isRecord } from '@/shared/object'; +import logger from '@/shared/logger'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import type { Request } from 'express'; +import UsersDBApi from '@/db/api/users'; +import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens'; +import ValidationError from '@/shared/errors/validation'; +import ForbiddenError from '@/shared/errors/forbidden'; +import EmailAddressVerificationEmail from '@/services/email/list/addressVerification'; +import InvitationEmail from '@/services/email/list/invitation'; +import PasswordResetEmail from '@/services/email/list/passwordReset'; +import EmailSender from '@/services/email'; +import config from '@/shared/config'; +import { jwtSign } from '@/shared/jwt'; +import db from '@/db/models'; +import { + GENERATED_ROLE_TO_PRODUCT_ROLE, + PRODUCT_ROLE_VALUES, + STAFF_TYPE_TO_PRODUCT_ROLE, + type ProductRoleValue, +} from '@/shared/constants/roles'; +import type { + AuthenticatedUser, + CurrentUser, + DbApiOptions, +} from '@/db/api/types'; +import type { + SessionOptions, + RoleDto, + OrganizationDto, + CampusDto, + StaffProfileDto, +} from '@/services/auth.types'; + +type PlainRecord = Record; + +/** Minimal user shape needed to mint a session (accepts a record or instance). */ +type SessionUser = Pick; + +type CreateFromAuthData = Parameters[0]; +type UpdateProfileData = Parameters[1]; + +function asStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asId(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** Returns the plain attributes of a Sequelize instance, or the record itself. */ +function toPlainRecord(record: unknown): PlainRecord | null { + if (!isRecord(record)) { + return null; + } + if (typeof record.get === 'function') { + const plain: unknown = record.get({ plain: true }); + return isRecord(plain) ? plain : null; + } + return record; +} + +function toRoleDto(role: unknown): RoleDto | null { + const plain = toPlainRecord(role); + if (!plain) return null; + return { + id: asStringOrNull(plain.id), + name: asStringOrNull(plain.name), + globalAccess: plain.globalAccess === true, + }; +} + +function toOrganizationDto(organization: unknown): OrganizationDto | null { + const plain = toPlainRecord(organization); + const id = asStringOrNull(plain?.id); + if (!plain || !id) return null; + return { + id, + name: asStringOrNull(plain.name), + }; +} + +function toCampusDto(campus: unknown): CampusDto | null { + const plain = toPlainRecord(campus); + const id = asStringOrNull(plain?.id); + if (!plain || !id) return null; + return { + id, + name: asStringOrNull(plain.name), + code: asStringOrNull(plain.code), + }; +} + +function toStaffProfileDto(staffProfile: unknown): StaffProfileDto | null { + const plain = toPlainRecord(staffProfile); + const id = asStringOrNull(plain?.id); + if (!plain || !id) return null; + return { + id, + employee_number: asStringOrNull(plain.employee_number), + job_title: asStringOrNull(plain.job_title), + staff_type: asStringOrNull(plain.staff_type), + status: asStringOrNull(plain.status), + organizationId: asStringOrNull(plain.organizationId), + campusId: asStringOrNull(plain.campusId), + userId: asStringOrNull(plain.userId), + }; +} + +function getPermissionNames( + rolePermissions: unknown, + customPermissions: unknown, +): string[] { + const appRolePermissions: unknown[] = Array.isArray(rolePermissions) + ? rolePermissions + : []; + const custom: unknown[] = Array.isArray(customPermissions) + ? customPermissions + : []; + + const names = [...appRolePermissions, ...custom] + .map((permission) => toPlainRecord(permission)) + .filter( + (permission): permission is { name: string } => + isRecord(permission) && typeof permission.name === 'string', + ) + .map((permission) => permission.name); + + return [...new Set(names)]; +} + +function getProductRole(role: unknown, staffProfile: unknown): ProductRoleValue { + const roleDto = toRoleDto(role); + const staffProfileDto = toStaffProfileDto(staffProfile); + + if (roleDto?.name && GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]) { + return GENERATED_ROLE_TO_PRODUCT_ROLE[roleDto.name]; + } + + if ( + staffProfileDto?.staff_type && + STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type] + ) { + return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfileDto.staff_type]; + } + + return PRODUCT_ROLE_VALUES.TEACHER; +} + +function getTokenPayload(user: SessionUser) { + return { + user: { + id: user.id, + email: user.email, + }, + }; +} + +function getRequestUserAgent(req: Request): string | null { + const header = req.headers['user-agent']; + return typeof header === 'string' ? header : null; +} + +function getRequestIp(req: Request): string | null { + return req.ip || req.socket?.remoteAddress || null; +} + +function generateOpaqueRefreshToken(): string { + return crypto + .randomBytes(config.auth.refreshTokenBytes) + .toString('base64url'); +} + +function hashRefreshToken(token: string): string { + return crypto + .createHash(config.auth.refreshTokenHashAlgorithm) + .update(token) + .digest('hex'); +} + +function getRefreshTokenExpiry(): Date { + return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs); +} + +class Auth { + static async currentUserProfile(currentUser?: CurrentUser) { + if (!currentUser || !currentUser.id) { + throw new ForbiddenError(); + } + + const user = await UsersDBApi.findProfileById(currentUser.id); + + if (!user) { + throw new ForbiddenError(); + } + + const staffProfile: unknown = + user.staff_user.length > 0 ? user.staff_user[0] : null; + const staffProfileDto = toStaffProfileDto(staffProfile); + const campusDto = toCampusDto(user.staff_campus); + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + organizationId: user.organizationId, + organizations: toOrganizationDto(user.organizations), + app_role: toRoleDto(user.app_role), + productRole: getProductRole(user.app_role, staffProfile), + staffProfile: staffProfileDto, + campus: campusDto, + campusId: campusDto ? campusDto.id : (staffProfileDto?.campusId ?? null), + permissions: getPermissionNames( + user.app_role_permissions, + user.custom_permissions, + ), + }; + } + + static async createSession( + user: SessionUser, + req: Request, + options: SessionOptions = {}, + ) { + const refreshToken = generateOpaqueRefreshToken(); + const familyId = options.familyId || crypto.randomUUID(); + const tokenRecord = await AuthRefreshTokensDBApi.create( + { + userId: asId(user.id), + organizationId: asStringOrNull(user.organizationId), + tokenHash: hashRefreshToken(refreshToken), + familyId, + previousTokenId: options.previousTokenId || null, + userAgent: getRequestUserAgent(req), + ipAddress: getRequestIp(req), + expiresAt: getRefreshTokenExpiry(), + }, + options, + ); + + return { + accessToken: jwtSign(getTokenPayload(user)), + refreshToken, + refreshTokenRecord: tokenRecord, + user, + }; + } + + static async refreshSession(refreshToken: string | undefined, req: Request) { + if (!refreshToken) { + throw new ForbiddenError(); + } + + const tokenHash = hashRefreshToken(refreshToken); + const transaction = await db.sequelize.transaction(); + let committed = false; + + try { + const existingToken = await AuthRefreshTokensDBApi.findByHash(tokenHash, { + transaction, + }); + + if (!existingToken) { + throw new ForbiddenError(); + } + + if (existingToken.revokedAt) { + await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, { + transaction, + }); + await transaction.commit(); + committed = true; + throw new ForbiddenError(); + } + + if (new Date(existingToken.expiresAt).getTime() <= Date.now()) { + await AuthRefreshTokensDBApi.revoke(existingToken.id, null, { + transaction, + }); + await transaction.commit(); + committed = true; + throw new ForbiddenError(); + } + + const user = await UsersDBApi.findBy({ id: existingToken.userId }); + + if (!user || user.disabled) { + await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, { + transaction, + }); + await transaction.commit(); + committed = true; + throw new ForbiddenError(); + } + + const nextSession = await this.createSession(user, req, { + familyId: existingToken.familyId, + previousTokenId: existingToken.id, + transaction, + }); + + await AuthRefreshTokensDBApi.revoke( + existingToken.id, + nextSession.refreshTokenRecord.id, + { transaction }, + ); + + await transaction.commit(); + committed = true; + return nextSession; + } catch (error) { + if (!committed) { + await transaction.rollback(); + } + throw error; + } + } + + static async revokeSession(refreshToken: string | undefined) { + if (!refreshToken) { + return; + } + + const tokenRecord = await AuthRefreshTokensDBApi.findByHash( + hashRefreshToken(refreshToken), + ); + + if (!tokenRecord || tokenRecord.revokedAt) { + return; + } + + await AuthRefreshTokensDBApi.revoke(tokenRecord.id, null); + } + + static async signup( + email: string, + password: string, + organizationId: string | null, + options: DbApiOptions = {}, + host?: string, + ) { + const user = await UsersDBApi.findBy({ email }); + + const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds); + + if (user) { + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + await UsersDBApi.updatePassword(asId(user.id), hashedPassword, options); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(email, host); + } + + return { user }; + } + + const newUser = await UsersDBApi.createFromAuth( + { + firstName: email.split('@')[0], + password: hashedPassword, + email, + organizationId, + } satisfies CreateFromAuthData, + options, + ); + + if (EmailSender.isConfigured) { + await this.sendEmailAddressVerificationEmail(email, host); + } + + return { user: newUser }; + } + + static async signin(email: string, password: string) { + const user = await UsersDBApi.findBy({ email }); + + if (!user) { + throw new ValidationError('auth.userNotFound'); + } + + if (user.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user.password) { + throw new ValidationError('auth.wrongPassword'); + } + + if (!EmailSender.isConfigured) { + user.emailVerified = true; + } + + if (!user.emailVerified) { + throw new ValidationError('auth.userNotVerified'); + } + + const passwordsMatch = await bcrypt.compare(password, String(user.password)); + + if (!passwordsMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + return { user }; + } + + static async sendEmailAddressVerificationEmail(email: string, host?: string) { + let link: string; + try { + const token = await UsersDBApi.generateEmailVerificationToken(email); + link = `${host}/verify-email?token=${token}`; + } catch (error) { + logger.error('Failed to generate email verification token', error); + throw new ValidationError('auth.emailAddressVerificationEmail.error'); + } + + const emailAddressVerificationEmail = new EmailAddressVerificationEmail( + email, + link, + ); + + return new EmailSender(emailAddressVerificationEmail).send(); + } + + static async sendPasswordResetEmail( + email: string, + type: 'register' | 'invitation' = 'register', + host?: string, + ) { + let link: string; + try { + const token = await UsersDBApi.generatePasswordResetToken(email); + link = `${host}/password-reset?token=${token}`; + } catch (error) { + logger.error('Failed to generate password reset token', error); + throw new ValidationError('auth.passwordReset.error'); + } + + const passwordResetEmail = + type === 'invitation' + ? new InvitationEmail(email, link) + : new PasswordResetEmail(email, link); + + return new EmailSender(passwordResetEmail).send(); + } + + static async verifyEmail(token: string, options: DbApiOptions = {}) { + const user = await UsersDBApi.findByEmailVerificationToken(token, options); + + if (!user) { + throw new ValidationError( + 'auth.emailAddressVerificationEmail.invalidToken', + ); + } + + return UsersDBApi.markEmailVerified(user.id, options); + } + + static async passwordUpdate( + currentPassword: string, + newPassword: string, + options: DbApiOptions, + ) { + const currentUser = options.currentUser ?? null; + if (!currentUser) { + throw new ForbiddenError(); + } + + const storedPassword = String(currentUser.password ?? ''); + + const currentPasswordMatch = await bcrypt.compare( + currentPassword, + storedPassword, + ); + + if (!currentPasswordMatch) { + throw new ValidationError('auth.wrongPassword'); + } + + const newPasswordMatch = await bcrypt.compare(newPassword, storedPassword); + + if (newPasswordMatch) { + throw new ValidationError('auth.passwordUpdate.samePassword'); + } + + const hashedPassword = await bcrypt.hash( + newPassword, + config.bcrypt.saltRounds, + ); + + return UsersDBApi.updatePassword(asId(currentUser.id), hashedPassword, options); + } + + static async passwordReset( + token: string, + password: string, + options: DbApiOptions = {}, + ) { + const user = await UsersDBApi.findByPasswordResetToken(token, options); + + if (!user) { + throw new ValidationError('auth.passwordReset.invalidToken'); + } + + const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds); + + return UsersDBApi.updatePassword(user.id, hashedPassword, options); + } + + static async updateProfile(data: UpdateProfileData, currentUser: CurrentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await UsersDBApi.update(asId(currentUser.id), data, false, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +export default Auth; diff --git a/backend/src/services/auth.types.ts b/backend/src/services/auth.types.ts new file mode 100644 index 0000000..ca3fcdf --- /dev/null +++ b/backend/src/services/auth.types.ts @@ -0,0 +1,35 @@ +import type { DbApiOptions } from '@/db/api/types'; + +export interface SessionOptions extends DbApiOptions { + familyId?: string; + previousTokenId?: string | null; +} + +/** Shapes of the `GET /me` profile response, built by the auth service mappers. */ +export interface RoleDto { + id: string | null; + name: string | null; + globalAccess: boolean; +} + +export interface OrganizationDto { + id: string; + name: string | null; +} + +export interface CampusDto { + id: string; + name: string | null; + code: string | null; +} + +export interface StaffProfileDto { + id: string; + employee_number: string | null; + job_title: string | null; + staff_type: string | null; + status: string | null; + organizationId: string | null; + campusId: string | null; + userId: string | null; +} diff --git a/backend/src/services/campus_attendance.js b/backend/src/services/campus_attendance.js deleted file mode 100644 index 5546340..0000000 --- a/backend/src/services/campus_attendance.js +++ /dev/null @@ -1,374 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { - CAMPUS_ATTENDANCE_DEFAULT_LIMIT, - CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, - CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, - CAMPUS_ATTENDANCE_MAX_LIMIT, - CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, - getProductRole, - normalizeCampusKey, -} = require('../constants/campus-attendance'); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id - || currentUser?.organization?.id - || currentUser?.organizationsId - || currentUser?.organizationId - || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return currentUser?.campusId || null; -} - -function getCurrentUserCampusKey(currentUser) { - const staffProfile = Array.isArray(currentUser?.staff_user) ? currentUser.staff_user[0] : null; - return normalizeCampusKey(currentUser?.campus?.code) - || normalizeCampusKey(currentUser?.campus?.name) - || normalizeCampusKey(staffProfile?.campus?.code) - || normalizeCampusKey(staffProfile?.campus?.name) - || null; -} - -function getDisplayName(currentUser) { - const firstName = currentUser?.firstName || ''; - const lastName = currentUser?.lastName || ''; - const fullName = `${firstName} ${lastName}`.trim(); - - return fullName || currentUser?.email || 'Staff Member'; -} - -function getRoleName(currentUser) { - return currentUser?.app_role?.name; -} - -function hasTenantWideAccess(currentUser) { - return currentUser?.app_role?.globalAccess === true - || CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); -} - -function canManageCampusAttendance(currentUser) { - return currentUser?.app_role?.globalAccess === true - || CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES.includes(getRoleName(currentUser)) - || CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES.includes(getProductRole(currentUser)); -} - -function assertAuthenticatedTenantUser(currentUser) { - if (currentUser?.id && getOrganizationId(currentUser)) { - return; - } - - throw new ForbiddenError(); -} - -function assertCanManageCampusAttendance(currentUser) { - assertAuthenticatedTenantUser(currentUser); - - if (canManageCampusAttendance(currentUser)) { - return; - } - - throw new ForbiddenError(); -} - -function campusKeyFromRoute(value) { - const campusKey = normalizeCampusKey(value); - - if (!campusKey) { - throw new ValidationError(); - } - - return campusKey; -} - -function assertCanAccessCampusKey(campusKey, currentUser) { - if (hasTenantWideAccess(currentUser)) { - return; - } - - const currentCampusKey = getCurrentUserCampusKey(currentUser); - - if (currentCampusKey && currentCampusKey === campusKey) { - return; - } - - throw new ForbiddenError(); -} - -function applyCampusScope(where, filter, currentUser) { - const requestedCampusKey = normalizeCampusKey(filter?.campusKey); - - if (requestedCampusKey) { - assertCanAccessCampusKey(requestedCampusKey, currentUser); - where.campus_key = requestedCampusKey; - return; - } - - if (hasTenantWideAccess(currentUser)) { - return; - } - - const currentCampusKey = getCurrentUserCampusKey(currentUser); - - if (!currentCampusKey) { - throw new ForbiddenError(); - } - - where.campus_key = currentCampusKey; -} - -function parseLimit(value) { - if (value === undefined) { - return CAMPUS_ATTENDANCE_DEFAULT_LIMIT; - } - - const limit = Number(value); - - if (!Number.isInteger(limit) || limit <= 0) { - throw new ValidationError(); - } - - return Math.min(limit, CAMPUS_ATTENDANCE_MAX_LIMIT); -} - -function requiredNonNegativeInteger(value) { - if (!Number.isInteger(value) || value < 0) { - throw new ValidationError(); - } - - return value; -} - -function optionalText(value) { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - return value.trim(); -} - -function requiredDate(value) { - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) { - throw new ValidationError(); - } - - return value; -} - -function validateSummary(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - const totalEnrolled = requiredNonNegativeInteger(data.total_enrolled); - const totalPresent = requiredNonNegativeInteger(data.total_present); - const totalAbsent = requiredNonNegativeInteger(data.total_absent); - const totalTardy = requiredNonNegativeInteger(data.total_tardy || 0); - - if (totalEnrolled <= 0 || totalPresent > totalEnrolled || totalAbsent > totalEnrolled || totalTardy > totalEnrolled) { - throw new ValidationError(); - } - - return { - total_enrolled: totalEnrolled, - total_present: totalPresent, - total_absent: totalAbsent, - total_tardy: totalTardy, - attendance_percentage: Number(((totalPresent / totalEnrolled) * 100).toFixed(2)), - notes: optionalText(data.notes), - }; -} - -function toConfigDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - campus_key: plainRecord.campus_key, - attendance_link: plainRecord.attendance_link, - updated_by_label: plainRecord.updated_by_label, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - createdById: plainRecord.createdById, - updatedById: plainRecord.updatedById, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -function toSummaryDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - campus_key: plainRecord.campus_key, - date: plainRecord.attendance_date, - total_enrolled: plainRecord.total_enrolled, - total_present: plainRecord.total_present, - total_absent: plainRecord.total_absent, - total_tardy: plainRecord.total_tardy, - attendance_percentage: Number(plainRecord.attendance_percentage), - recorded_by_label: plainRecord.recorded_by_label, - notes: plainRecord.notes, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - createdById: plainRecord.createdById, - updatedById: plainRecord.updatedById, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -module.exports = class CampusAttendanceService { - static async listConfigs(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - applyCampusScope(where, filter, currentUser); - - const result = await db.campus_attendance_config.findAndCountAll({ - where, - order: [['campus_key', 'asc']], - }); - - return { - rows: result.rows.map(toConfigDto), - count: result.count, - }; - } - - static async upsertConfig(campusKeyParam, data, currentUser) { - assertCanManageCampusAttendance(currentUser); - - const campusKey = campusKeyFromRoute(campusKeyParam); - assertCanAccessCampusKey(campusKey, currentUser); - - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - const attendanceLink = optionalText(data.attendance_link); - const where = { - organizationId: getOrganizationId(currentUser), - campus_key: campusKey, - }; - const payload = { - campus_key: campusKey, - attendance_link: attendanceLink, - updated_by_label: getDisplayName(currentUser), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - updatedById: currentUser.id, - }; - const transaction = await db.sequelize.transaction(); - - try { - const existing = await db.campus_attendance_config.findOne({ where, transaction }); - const saved = existing - ? await existing.update(payload, { transaction }) - : await db.campus_attendance_config.create( - { - ...payload, - createdById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toConfigDto(saved); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async listSummaries(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - applyCampusScope(where, filter, currentUser); - - if (filter.startDate) { - where.attendance_date = { - ...(where.attendance_date || {}), - [db.Sequelize.Op.gte]: requiredDate(filter.startDate), - }; - } - - if (filter.endDate) { - where.attendance_date = { - ...(where.attendance_date || {}), - [db.Sequelize.Op.lte]: requiredDate(filter.endDate), - }; - } - - const result = await db.campus_attendance_summaries.findAndCountAll({ - where, - limit: parseLimit(filter.limit), - order: [['attendance_date', 'desc'], ['campus_key', 'asc']], - }); - - return { - rows: result.rows.map(toSummaryDto), - count: result.count, - }; - } - - static async upsertSummary(campusKeyParam, dateParam, data, currentUser) { - assertCanManageCampusAttendance(currentUser); - - const campusKey = campusKeyFromRoute(campusKeyParam); - const attendanceDate = requiredDate(dateParam); - assertCanAccessCampusKey(campusKey, currentUser); - - const validatedSummary = validateSummary(data); - const where = { - organizationId: getOrganizationId(currentUser), - campus_key: campusKey, - attendance_date: attendanceDate, - }; - const payload = { - ...validatedSummary, - campus_key: campusKey, - attendance_date: attendanceDate, - recorded_by_label: getDisplayName(currentUser), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - updatedById: currentUser.id, - }; - const transaction = await db.sequelize.transaction(); - - try { - const existing = await db.campus_attendance_summaries.findOne({ where, transaction }); - const saved = existing - ? await existing.update(payload, { transaction }) - : await db.campus_attendance_summaries.create( - { - ...payload, - createdById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toSummaryDto(saved); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; diff --git a/backend/src/services/campus_attendance.ts b/backend/src/services/campus_attendance.ts new file mode 100644 index 0000000..475c66d --- /dev/null +++ b/backend/src/services/campus_attendance.ts @@ -0,0 +1,353 @@ +import { clampLimit, requiredIsoDate } from '@/services/shared/validate'; +import { Op } from 'sequelize'; +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { + CAMPUS_ATTENDANCE_DEFAULT_LIMIT, + CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, + CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, + CAMPUS_ATTENDANCE_MAX_LIMIT, + CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, + getProductRole, + normalizeCampusKey, +} from '@/shared/constants/campus-attendance'; +import { resolvePagination } from '@/shared/constants/pagination'; +import type { CampusAttendanceConfig } from '@/db/models/campus_attendance_config'; +import type { CampusAttendanceSummaries } from '@/db/models/campus_attendance_summaries'; +import type { CurrentUser } from '@/db/api/types'; +import type { + CampusAttendanceFilter, + ConfigInput, + SummaryInput, +} from '@/services/campus_attendance.types'; +import { + assertAuthenticatedTenantUser, + getCampusId, + hasRoleAccess, + requireOrganizationId, + requireUserId, + getDisplayName, +} from '@/services/shared/access'; + +function getCurrentUserCampusKey(currentUser?: CurrentUser): string | null { + const staff = currentUser?.staff_user; + const staffProfile = Array.isArray(staff) ? staff[0] : null; + + return ( + normalizeCampusKey(currentUser?.campus?.code) || + normalizeCampusKey(currentUser?.campus?.name) || + normalizeCampusKey(staffProfile?.campus?.code) || + normalizeCampusKey(staffProfile?.campus?.name) || + null + ); +} + +function canManageCampusAttendance(currentUser?: CurrentUser): boolean { + const productRole = getProductRole(currentUser); + return ( + hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES) || + CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES.some((role) => role === productRole) + ); +} + +function assertCanManageCampusAttendance(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + + if (canManageCampusAttendance(currentUser)) { + return; + } + + throw new ForbiddenError(); +} + +function campusKeyFromRoute(value: unknown): string { + const campusKey = normalizeCampusKey(value); + + if (!campusKey) { + throw new ValidationError(); + } + + return campusKey; +} + +function assertCanAccessCampusKey( + campusKey: string, + currentUser?: CurrentUser, +): void { + if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) { + return; + } + + const currentCampusKey = getCurrentUserCampusKey(currentUser); + + if (currentCampusKey && currentCampusKey === campusKey) { + return; + } + + throw new ForbiddenError(); +} + +/** Resolves the campus_key scope, asserting access along the way. */ +function campusScope( + filter: CampusAttendanceFilter, + currentUser?: CurrentUser, +): { campus_key?: string } { + const requestedCampusKey = normalizeCampusKey(filter?.campusKey); + + if (requestedCampusKey) { + assertCanAccessCampusKey(requestedCampusKey, currentUser); + return { campus_key: requestedCampusKey }; + } + + if (hasRoleAccess(currentUser, CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES)) { + return {}; + } + + const currentCampusKey = getCurrentUserCampusKey(currentUser); + + if (!currentCampusKey) { + throw new ForbiddenError(); + } + + return { campus_key: currentCampusKey }; +} + +function dateRange(filter: CampusAttendanceFilter): { + attendance_date?: { [Op.gte]?: string; [Op.lte]?: string }; +} { + const start = filter.startDate ? requiredIsoDate(filter.startDate) : null; + const end = filter.endDate ? requiredIsoDate(filter.endDate) : null; + + if (!start && !end) { + return {}; + } + + return { + attendance_date: { + ...(start ? { [Op.gte]: start } : {}), + ...(end ? { [Op.lte]: end } : {}), + }, + }; +} + +function requiredNonNegativeInteger(value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new ValidationError(); + } + + return value; +} + +function optionalText(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + return value.trim(); +} + +function validateSummary(data: SummaryInput) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const totalEnrolled = requiredNonNegativeInteger(data.total_enrolled); + const totalPresent = requiredNonNegativeInteger(data.total_present); + const totalAbsent = requiredNonNegativeInteger(data.total_absent); + const totalTardy = requiredNonNegativeInteger(data.total_tardy ?? 0); + + if ( + totalEnrolled <= 0 || + totalPresent > totalEnrolled || + totalAbsent > totalEnrolled || + totalTardy > totalEnrolled + ) { + throw new ValidationError(); + } + + return { + total_enrolled: totalEnrolled, + total_present: totalPresent, + total_absent: totalAbsent, + total_tardy: totalTardy, + attendance_percentage: ((totalPresent / totalEnrolled) * 100).toFixed(2), + notes: optionalText(data.notes), + }; +} + +function toConfigDto(record: CampusAttendanceConfig) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + campus_key: plain.campus_key, + attendance_link: plain.attendance_link, + updated_by_label: plain.updated_by_label, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +function toSummaryDto(record: CampusAttendanceSummaries) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + campus_key: plain.campus_key, + date: plain.attendance_date, + total_enrolled: plain.total_enrolled, + total_present: plain.total_present, + total_absent: plain.total_absent, + total_tardy: plain.total_tardy, + attendance_percentage: Number(plain.attendance_percentage), + recorded_by_label: plain.recorded_by_label, + notes: plain.notes, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class CampusAttendanceService { + static async listConfigs( + filter: CampusAttendanceFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const result = await db.campus_attendance_config.findAndCountAll({ + where: { + organizationId: requireOrganizationId(currentUser), + ...campusScope(filter, currentUser), + }, + order: [['campus_key', 'asc']], + limit, + offset, + }); + + return { + rows: result.rows.map(toConfigDto), + count: result.count, + }; + } + + static async upsertConfig( + campusKeyParam: unknown, + data: ConfigInput, + currentUser?: CurrentUser, + ) { + assertCanManageCampusAttendance(currentUser); + + const campusKey = campusKeyFromRoute(campusKeyParam); + assertCanAccessCampusKey(campusKey, currentUser); + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const payload = { + campus_key: campusKey, + attendance_link: optionalText(data.attendance_link), + updated_by_label: getDisplayName(currentUser), + organizationId: requireOrganizationId(currentUser), + campusId: getCampusId(currentUser), + updatedById: currentUser?.id ?? null, + }; + return withTransaction(async (transaction) => { + const existing = await db.campus_attendance_config.findOne({ + where: { + organizationId: requireOrganizationId(currentUser), + campus_key: campusKey, + }, + transaction, + }); + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.campus_attendance_config.create( + { ...payload, createdById: requireUserId(currentUser) }, + { transaction }, + ); + + return toConfigDto(saved); + }); + } + + static async listSummaries( + filter: CampusAttendanceFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + const result = await db.campus_attendance_summaries.findAndCountAll({ + where: { + organizationId: requireOrganizationId(currentUser), + ...campusScope(filter, currentUser), + ...dateRange(filter), + }, + limit: clampLimit(filter.limit, CAMPUS_ATTENDANCE_DEFAULT_LIMIT, CAMPUS_ATTENDANCE_MAX_LIMIT), + order: [ + ['attendance_date', 'desc'], + ['campus_key', 'asc'], + ], + }); + + return { + rows: result.rows.map(toSummaryDto), + count: result.count, + }; + } + + static async upsertSummary( + campusKeyParam: unknown, + dateParam: unknown, + data: SummaryInput, + currentUser?: CurrentUser, + ) { + assertCanManageCampusAttendance(currentUser); + + const campusKey = campusKeyFromRoute(campusKeyParam); + const attendanceDate = requiredIsoDate(dateParam); + assertCanAccessCampusKey(campusKey, currentUser); + + const validatedSummary = validateSummary(data); + const payload = { + ...validatedSummary, + campus_key: campusKey, + attendance_date: attendanceDate, + recorded_by_label: getDisplayName(currentUser), + organizationId: requireOrganizationId(currentUser), + campusId: getCampusId(currentUser), + updatedById: currentUser?.id ?? null, + }; + return withTransaction(async (transaction) => { + const existing = await db.campus_attendance_summaries.findOne({ + where: { + organizationId: requireOrganizationId(currentUser), + campus_key: campusKey, + attendance_date: attendanceDate, + }, + transaction, + }); + const saved = existing + ? await existing.update(payload, { transaction }) + : await db.campus_attendance_summaries.create( + { ...payload, createdById: requireUserId(currentUser) }, + { transaction }, + ); + + return toSummaryDto(saved); + }); + } +} + +export default CampusAttendanceService; diff --git a/backend/src/services/campus_attendance.types.ts b/backend/src/services/campus_attendance.types.ts new file mode 100644 index 0000000..8eca16d --- /dev/null +++ b/backend/src/services/campus_attendance.types.ts @@ -0,0 +1,19 @@ +export interface CampusAttendanceFilter { + campusKey?: unknown; + startDate?: unknown; + endDate?: unknown; + limit?: number | string; + page?: number | string; +} + +export interface ConfigInput { + attendance_link?: unknown; +} + +export interface SummaryInput { + total_enrolled?: unknown; + total_present?: unknown; + total_absent?: unknown; + total_tardy?: unknown; + notes?: unknown; +} diff --git a/backend/src/services/campus_catalog.js b/backend/src/services/campus_catalog.js deleted file mode 100644 index 53761e9..0000000 --- a/backend/src/services/campus_catalog.js +++ /dev/null @@ -1,48 +0,0 @@ -const db = require('../db/models'); - -function toCampusCatalogDto(campus) { - const plainCampus = campus.get({ plain: true }); - - return { - id: plainCampus.id, - name: plainCampus.name, - code: plainCampus.code, - mascot: plainCampus.mascot, - color: plainCampus.color, - bgGradient: plainCampus.bgGradient, - borderColor: plainCampus.borderColor, - textColor: plainCampus.textColor, - bgLight: plainCampus.bgLight, - description: plainCampus.description, - isOnline: plainCampus.isOnline, - }; -} - -module.exports = class CampusCatalogService { - static async listActive() { - const rows = await db.campuses.findAll({ - attributes: [ - 'id', - 'name', - 'code', - 'mascot', - 'color', - 'bgGradient', - 'borderColor', - 'textColor', - 'bgLight', - 'description', - 'isOnline', - ], - where: { - active: true, - }, - order: [['name', 'ASC']], - }); - - return { - rows: rows.map(toCampusCatalogDto), - count: rows.length, - }; - } -}; diff --git a/backend/src/services/campus_catalog.ts b/backend/src/services/campus_catalog.ts new file mode 100644 index 0000000..499ebbb --- /dev/null +++ b/backend/src/services/campus_catalog.ts @@ -0,0 +1,49 @@ +import db from '@/db/models'; +import type { Campuses } from '@/db/models/campuses'; + +function toCampusCatalogDto(campus: Campuses) { + const plain = campus.get({ plain: true }); + + return { + id: plain.id, + name: plain.name, + code: plain.code, + mascot: plain.mascot, + color: plain.color, + bgGradient: plain.bgGradient, + borderColor: plain.borderColor, + textColor: plain.textColor, + bgLight: plain.bgLight, + description: plain.description, + isOnline: plain.isOnline, + }; +} + +class CampusCatalogService { + static async listActive() { + const rows = await db.campuses.findAll({ + attributes: [ + 'id', + 'name', + 'code', + 'mascot', + 'color', + 'bgGradient', + 'borderColor', + 'textColor', + 'bgLight', + 'description', + 'isOnline', + ], + where: { active: true }, + order: [['name', 'ASC']], + }); + + return { + rows: rows.map(toCampusCatalogDto), + count: rows.length, + }; + } +} + +export default CampusCatalogService; diff --git a/backend/src/services/campuses.js b/backend/src/services/campuses.js deleted file mode 100644 index 89d51c1..0000000 --- a/backend/src/services/campuses.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const CampusesDBApi = require('../db/api/campuses'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class CampusesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await CampusesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await CampusesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let campuses = await CampusesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!campuses) { - throw new ValidationError( - 'campusesNotFound', - ); - } - - const updatedCampuses = await CampusesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedCampuses; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await CampusesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await CampusesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/campuses.ts b/backend/src/services/campuses.ts new file mode 100644 index 0000000..faea810 --- /dev/null +++ b/backend/src/services/campuses.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/campuses'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'campusesNotFound' }); diff --git a/backend/src/services/class_enrollments.js b/backend/src/services/class_enrollments.js deleted file mode 100644 index 42e33f0..0000000 --- a/backend/src/services/class_enrollments.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Class_enrollmentsDBApi = require('../db/api/class_enrollments'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Class_enrollmentsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Class_enrollmentsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Class_enrollmentsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let class_enrollments = await Class_enrollmentsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!class_enrollments) { - throw new ValidationError( - 'class_enrollmentsNotFound', - ); - } - - const updatedClass_enrollments = await Class_enrollmentsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedClass_enrollments; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Class_enrollmentsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Class_enrollmentsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/class_enrollments.ts b/backend/src/services/class_enrollments.ts new file mode 100644 index 0000000..09b711f --- /dev/null +++ b/backend/src/services/class_enrollments.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/class_enrollments'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'class_enrollmentsNotFound' }); diff --git a/backend/src/services/class_subjects.js b/backend/src/services/class_subjects.js deleted file mode 100644 index 4e2019c..0000000 --- a/backend/src/services/class_subjects.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Class_subjectsDBApi = require('../db/api/class_subjects'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Class_subjectsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Class_subjectsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Class_subjectsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let class_subjects = await Class_subjectsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!class_subjects) { - throw new ValidationError( - 'class_subjectsNotFound', - ); - } - - const updatedClass_subjects = await Class_subjectsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedClass_subjects; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Class_subjectsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Class_subjectsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/class_subjects.ts b/backend/src/services/class_subjects.ts new file mode 100644 index 0000000..70d5ddf --- /dev/null +++ b/backend/src/services/class_subjects.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/class_subjects'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'class_subjectsNotFound' }); diff --git a/backend/src/services/classes.js b/backend/src/services/classes.js deleted file mode 100644 index 0c78aa4..0000000 --- a/backend/src/services/classes.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const ClassesDBApi = require('../db/api/classes'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class ClassesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await ClassesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await ClassesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let classes = await ClassesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!classes) { - throw new ValidationError( - 'classesNotFound', - ); - } - - const updatedClasses = await ClassesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedClasses; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await ClassesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await ClassesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/classes.ts b/backend/src/services/classes.ts new file mode 100644 index 0000000..c2abc8b --- /dev/null +++ b/backend/src/services/classes.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/classes'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'classesNotFound' }); diff --git a/backend/src/services/communications.js b/backend/src/services/communications.js deleted file mode 100644 index 0f28524..0000000 --- a/backend/src/services/communications.js +++ /dev/null @@ -1,285 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { - COMMUNICATION_AUDIENCES, - COMMUNICATION_CHANNELS, - COMMUNICATION_EVENT_TYPES, - COMMUNICATION_MANAGER_ROLE_NAMES, - COMMUNICATION_RECIPIENT_TYPES, - COMMUNICATION_STATUSES, - COMMUNICATION_TENANT_WIDE_ROLE_NAMES, -} = require('../constants/communications'); - -const DEFAULT_EVENT_ROLES = Object.freeze(['teacher', 'para', 'office', 'director']); -const EVENT_TYPES = Object.freeze(Object.values(COMMUNICATION_EVENT_TYPES)); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id - || currentUser?.organization?.id - || currentUser?.organizationsId - || currentUser?.organizationId - || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return currentUser?.campusId || null; -} - -function getRoleName(currentUser) { - return currentUser?.app_role?.name; -} - -function hasTenantWideAccess(currentUser) { - return currentUser?.app_role?.globalAccess === true - || COMMUNICATION_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); -} - -function assertAuthenticatedTenantUser(currentUser) { - if (currentUser?.id && getOrganizationId(currentUser)) { - return; - } - - throw new ForbiddenError(); -} - -function assertCanManageCommunications(currentUser) { - assertAuthenticatedTenantUser(currentUser); - - if ( - currentUser?.app_role?.globalAccess === true - || COMMUNICATION_MANAGER_ROLE_NAMES.includes(getRoleName(currentUser)) - ) { - return; - } - - throw new ForbiddenError(); -} - -function applyCampusScope(where, currentUser) { - if (hasTenantWideAccess(currentUser)) { - return; - } - - const campusId = getCampusId(currentUser); - - if (campusId) { - where.campusId = campusId; - } -} - -function nullableString(value) { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - return value.trim(); -} - -function requiredString(value) { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new ValidationError(); - } - - return value.trim(); -} - -function validateRoles(roles) { - if (!Array.isArray(roles) || roles.length === 0) { - return DEFAULT_EVENT_ROLES; - } - - if (!roles.every((role) => typeof role === 'string' && role.trim().length > 0)) { - throw new ValidationError(); - } - - return roles.map((role) => role.trim()); -} - -function toParentMessageDto(message) { - const plainMessage = typeof message.get === 'function' - ? message.get({ plain: true }) - : message; - const firstRecipient = plainMessage.message_recipients_message?.[0] || null; - - return { - id: plainMessage.id, - text: plainMessage.body, - to: firstRecipient?.recipient_label || '', - date: new Date(plainMessage.sent_at || plainMessage.createdAt).toLocaleString('en-US'), - category: plainMessage.subject, - sentAt: plainMessage.sent_at, - organizationId: plainMessage.organizationId, - campusId: plainMessage.campusId, - createdById: plainMessage.createdById, - updatedById: plainMessage.updatedById, - createdAt: plainMessage.createdAt, - updatedAt: plainMessage.updatedAt, - }; -} - -function toCommunicationEventDto(event) { - const plainEvent = typeof event.get === 'function' - ? event.get({ plain: true }) - : event; - - return { - id: plainEvent.id, - title: plainEvent.title, - date: plainEvent.event_date, - type: plainEvent.event_type, - roles: plainEvent.roles, - organizationId: plainEvent.organizationId, - campusId: plainEvent.campusId, - createdById: plainEvent.createdById, - updatedById: plainEvent.updatedById, - createdAt: plainEvent.createdAt, - updatedAt: plainEvent.updatedAt, - }; -} - -module.exports = class CommunicationsService { - static async listParentMessages(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - createdById: currentUser.id, - audience: COMMUNICATION_AUDIENCES.GUARDIANS, - }; - applyCampusScope(where, currentUser); - - if (filter.category) { - where.subject = filter.category; - } - - const result = await db.messages.findAndCountAll({ - where, - include: [ - { - model: db.message_recipients, - as: 'message_recipients_message', - }, - ], - distinct: true, - order: [['sent_at', 'desc'], ['createdAt', 'desc']], - }); - - return { - rows: result.rows.map(toParentMessageDto), - count: result.count, - }; - } - - static async createParentMessage(data, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const recipientName = requiredString(data?.recipientName); - const messageText = requiredString(data?.messageText); - const category = nullableString(data?.category) || 'general'; - const transaction = await db.sequelize.transaction(); - - try { - const createdMessage = await db.messages.create( - { - subject: category, - body: messageText, - channel: COMMUNICATION_CHANNELS.IN_APP, - audience: COMMUNICATION_AUDIENCES.GUARDIANS, - sent_at: new Date(), - status: COMMUNICATION_STATUSES.SENT, - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - sent_byId: currentUser.id, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await db.message_recipients.create( - { - recipient_type: COMMUNICATION_RECIPIENT_TYPES.GUARDIAN, - recipient_label: recipientName, - destination: null, - delivery_status: COMMUNICATION_STATUSES.SENT, - delivered_at: new Date(), - read_at: null, - organizationId: getOrganizationId(currentUser), - messageId: createdMessage.id, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - - const savedMessage = await db.messages.findByPk(createdMessage.id, { - include: [ - { - model: db.message_recipients, - as: 'message_recipients_message', - }, - ], - }); - - return toParentMessageDto(savedMessage); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async listEvents(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - applyCampusScope(where, currentUser); - - if (filter.type) { - where.event_type = filter.type; - } - - const result = await db.communication_events.findAndCountAll({ - where, - order: [['event_date', 'asc'], ['createdAt', 'desc']], - }); - - return { - rows: result.rows.map(toCommunicationEventDto), - count: result.count, - }; - } - - static async createEvent(data, currentUser) { - assertCanManageCommunications(currentUser); - - const title = requiredString(data?.title); - const date = requiredString(data?.date); - const type = requiredString(data?.type); - - if (!EVENT_TYPES.includes(type)) { - throw new ValidationError(); - } - - const createdEvent = await db.communication_events.create({ - title, - event_date: date, - event_type: type, - roles: validateRoles(data?.roles), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - createdById: currentUser.id, - updatedById: currentUser.id, - }); - - return toCommunicationEventDto(createdEvent); - } -}; diff --git a/backend/src/services/communications.ts b/backend/src/services/communications.ts new file mode 100644 index 0000000..859b884 --- /dev/null +++ b/backend/src/services/communications.ts @@ -0,0 +1,305 @@ +import { nullableString } from '@/services/shared/validate'; +import { isRecord } from '@/shared/object'; +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { + assertAuthenticatedTenantUser, + campusScope, + getCampusId, + getOrganizationId, + getOrganizationIdOrGlobal, + hasGlobalAccess, + hasRoleAccess, + requireOrganizationId, + requireUserId, +} from '@/services/shared/access'; +import { + COMMUNICATION_AUDIENCES, + COMMUNICATION_CHANNELS, + COMMUNICATION_EVENT_TYPE_VALUES, + type CommunicationEventType, + COMMUNICATION_MANAGER_ROLE_NAMES, + COMMUNICATION_RECIPIENT_TYPES, + COMMUNICATION_STATUSES, + COMMUNICATION_TENANT_WIDE_ROLE_NAMES, + DEFAULT_PARENT_MESSAGE_CATEGORY, + PARENT_MESSAGE_CATEGORY_VALUES, + type ParentMessageCategory, +} from '@/shared/constants/communications'; +import { + PRODUCT_ROLE_VALUES, + type ProductRoleValue, +} from '@/shared/constants/roles'; +import { resolvePagination } from '@/shared/constants/pagination'; +import type { Messages } from '@/db/models/messages'; +import type { CommunicationEvents } from '@/db/models/communication_events'; +import type { CurrentUser } from '@/db/api/types'; +import type { + ParentMessageFilter, + ParentMessageInput, + EventFilter, + EventInput, +} from '@/services/communications.types'; + +const DEFAULT_EVENT_ROLES: ProductRoleValue[] = [ + 'teacher', + 'para', + 'office', + 'director', +]; +const PRODUCT_ROLE_VALUE_LIST: readonly ProductRoleValue[] = + Object.values(PRODUCT_ROLE_VALUES); + +function requireEventType(value: unknown): CommunicationEventType { + const type = requiredString(value); + const match = COMMUNICATION_EVENT_TYPE_VALUES.find((item) => item === type); + if (!match) { + throw new ValidationError(); + } + return match; +} + +function assertCanManageCommunications(currentUser?: CurrentUser): void { + assertAuthenticatedTenantUser(currentUser); + + if (hasRoleAccess(currentUser, COMMUNICATION_MANAGER_ROLE_NAMES)) { + return; + } + + throw new ForbiddenError(); +} + +function requiredString(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ValidationError(); + } + + return value.trim(); +} + +function validateRoles(roles: unknown): ProductRoleValue[] { + if (!Array.isArray(roles) || roles.length === 0) { + return [...DEFAULT_EVENT_ROLES]; + } + + return roles.map((role: unknown) => { + const normalized = typeof role === 'string' ? role.trim() : role; + const match = PRODUCT_ROLE_VALUE_LIST.find((item) => item === normalized); + if (!match) { + throw new ValidationError(); + } + return match; + }); +} + +function toParentMessageCategory(subject: unknown): ParentMessageCategory { + const match = PARENT_MESSAGE_CATEGORY_VALUES.find((item) => item === subject); + return match ?? DEFAULT_PARENT_MESSAGE_CATEGORY; +} + +function toIsoString(value: Date | string | null): string { + return new Date(value ?? Date.now()).toISOString(); +} + +function toParentMessageDto(message: Messages | null) { + if (!message) { + return null; + } + + const plain = message.get({ plain: true }); + const recipients = message.get('message_recipients_message'); + const firstRecipient = Array.isArray(recipients) ? recipients[0] : null; + const recipientLabel = + isRecord(firstRecipient) && typeof firstRecipient.recipient_label === 'string' + ? firstRecipient.recipient_label + : ''; + + return { + id: plain.id, + text: plain.body ?? '', + to: recipientLabel, + date: toIsoString(plain.sent_at ?? plain.createdAt), + category: toParentMessageCategory(plain.subject), + sentAt: toIsoString(plain.sent_at ?? plain.createdAt), + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +function toCommunicationEventDto(event: CommunicationEvents) { + const plain = event.get({ plain: true }); + + return { + id: plain.id, + title: plain.title, + date: plain.event_date, + type: plain.event_type, + roles: plain.roles, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class CommunicationsService { + static async listParentMessages( + filter: ParentMessageFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + const createdByFilter = hasGlobalAccess(currentUser) + ? {} + : { createdById: currentUser?.id ?? null }; + + const result = await db.messages.findAndCountAll({ + where: { + ...orgFilter, + ...createdByFilter, + audience: COMMUNICATION_AUDIENCES.GUARDIANS, + ...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES), + ...(filter.category ? { subject: filter.category } : {}), + }, + include: [ + { + model: db.message_recipients, + as: 'message_recipients_message', + attributes: ['recipient_label'], + }, + ], + order: [ + ['sent_at', 'desc'], + ['createdAt', 'desc'], + ], + limit, + offset, + }); + + return { + rows: result.rows.map(toParentMessageDto), + count: result.count, + }; + } + + static async createParentMessage( + data: ParentMessageInput, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + const recipientName = requiredString(data?.recipientName); + const messageText = requiredString(data?.messageText); + const category = toParentMessageCategory(nullableString(data?.category)); + + const createdMessage = await withTransaction(async (transaction) => { + const message = await db.messages.create( + { + subject: category, + body: messageText, + channel: COMMUNICATION_CHANNELS.IN_APP, + audience: COMMUNICATION_AUDIENCES.GUARDIANS, + sent_at: new Date(), + status: COMMUNICATION_STATUSES.SENT, + organizationId: getOrganizationId(currentUser), + campusId: getCampusId(currentUser), + sent_byId: currentUser?.id ?? null, + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + await db.message_recipients.create( + { + recipient_type: COMMUNICATION_RECIPIENT_TYPES.GUARDIAN, + recipient_label: recipientName, + destination: null, + delivery_status: COMMUNICATION_STATUSES.SENT, + delivered_at: new Date(), + read_at: null, + organizationId: getOrganizationId(currentUser), + messageId: message.id, + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + return message; + }); + + const savedMessage = await db.messages.findByPk(createdMessage.id, { + include: [ + { + model: db.message_recipients, + as: 'message_recipients_message', + attributes: ['recipient_label'], + }, + ], + }); + + return toParentMessageDto(savedMessage); + } + + static async listEvents(filter: EventFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const result = await db.communication_events.findAndCountAll({ + where: { + ...orgFilter, + ...campusScope(currentUser, COMMUNICATION_TENANT_WIDE_ROLE_NAMES), + ...(filter.type ? { event_type: filter.type } : {}), + }, + order: [ + ['event_date', 'asc'], + ['createdAt', 'desc'], + ], + limit, + offset, + }); + + return { + rows: result.rows.map(toCommunicationEventDto), + count: result.count, + }; + } + + static async createEvent(data: EventInput, currentUser?: CurrentUser) { + assertCanManageCommunications(currentUser); + + const title = requiredString(data?.title); + const date = requiredString(data?.date); + const type = requireEventType(data?.type); + + const createdEvent = await db.communication_events.create({ + title, + event_date: date, + event_type: type, + roles: validateRoles(data?.roles), + organizationId: requireOrganizationId(currentUser), + campusId: getCampusId(currentUser), + createdById: requireUserId(currentUser), + updatedById: currentUser?.id ?? null, + }); + + return toCommunicationEventDto(createdEvent); + } +} + +export default CommunicationsService; diff --git a/backend/src/services/communications.types.ts b/backend/src/services/communications.types.ts new file mode 100644 index 0000000..e9a1c81 --- /dev/null +++ b/backend/src/services/communications.types.ts @@ -0,0 +1,24 @@ +export interface ParentMessageFilter { + category?: string; + limit?: number | string; + page?: number | string; +} + +export interface ParentMessageInput { + recipientName?: unknown; + messageText?: unknown; + category?: unknown; +} + +export interface EventFilter { + type?: string; + limit?: number | string; + page?: number | string; +} + +export interface EventInput { + title?: unknown; + date?: unknown; + type?: unknown; + roles?: unknown; +} diff --git a/backend/src/services/content_catalog.js b/backend/src/services/content_catalog.js deleted file mode 100644 index 6effabd..0000000 --- a/backend/src/services/content_catalog.js +++ /dev/null @@ -1,172 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { CONTENT_CATALOG_MANAGER_ROLE_NAMES } = require('../constants/content-catalog'); - -function toContentCatalogDto(record) { - const plainRecord = record.get({ plain: true }); - - return { - id: plainRecord.id, - content_type: plainRecord.content_type, - payload: plainRecord.payload, - updatedAt: plainRecord.updatedAt, - }; -} - -function assertCanManageContentCatalog(currentUser) { - const roleName = currentUser?.app_role?.name; - - if (currentUser?.app_role?.globalAccess === true || CONTENT_CATALOG_MANAGER_ROLE_NAMES.includes(roleName)) { - return; - } - - throw new ForbiddenError(); -} - -function assertValidContentType(contentType) { - if (typeof contentType !== 'string' || contentType.trim().length === 0) { - throw new ValidationError(); - } - - return contentType.trim(); -} - -function assertValidPayload(payload) { - if (payload === undefined) { - throw new ValidationError(); - } - - return payload; -} - -module.exports = class ContentCatalogService { - static async list(currentUser) { - assertCanManageContentCatalog(currentUser); - - const result = await db.content_catalog.findAndCountAll({ - order: [['content_type', 'asc']], - }); - - return { - rows: result.rows.map(toContentCatalogDto), - count: result.count, - }; - } - - static async findByType(contentType) { - const record = await db.content_catalog.findOne({ - where: { - content_type: assertValidContentType(contentType), - active: true, - }, - }); - - if (!record) { - throw new ValidationError('contentCatalogNotFound'); - } - - return toContentCatalogDto(record); - } - - static async findManagedByType(contentType, currentUser) { - assertCanManageContentCatalog(currentUser); - - return this.findByType(contentType); - } - - static async create(data, currentUser) { - assertCanManageContentCatalog(currentUser); - - const contentType = assertValidContentType(data?.content_type); - const payload = assertValidPayload(data?.payload); - - const existingRecord = await db.content_catalog.findOne({ - where: { content_type: contentType }, - paranoid: false, - }); - - if (existingRecord && !existingRecord.deletedAt) { - throw new ValidationError(); - } - - const transaction = await db.sequelize.transaction(); - - try { - const record = existingRecord - ? await existingRecord.restore({ transaction }).then(() => existingRecord.update({ - payload, - active: data.active !== false, - importHash: data.importHash || null, - }, { transaction })) - : await db.content_catalog.create({ - content_type: contentType, - payload, - active: data.active !== false, - importHash: data.importHash || null, - }, { transaction }); - - await transaction.commit(); - return toContentCatalogDto(record); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(contentType, data, currentUser) { - assertCanManageContentCatalog(currentUser); - - const normalizedContentType = assertValidContentType(contentType); - const payload = assertValidPayload(data?.payload); - const transaction = await db.sequelize.transaction(); - - try { - const record = await db.content_catalog.findOne({ - where: { content_type: normalizedContentType }, - transaction, - }); - - if (!record) { - throw new ValidationError('contentCatalogNotFound'); - } - - await record.update({ - payload, - active: data.active !== false, - }, { transaction }); - - await transaction.commit(); - return toContentCatalogDto(record); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async delete(contentType, currentUser) { - assertCanManageContentCatalog(currentUser); - - const normalizedContentType = assertValidContentType(contentType); - const transaction = await db.sequelize.transaction(); - - try { - const record = await db.content_catalog.findOne({ - where: { content_type: normalizedContentType }, - transaction, - }); - - if (!record) { - throw new ValidationError('contentCatalogNotFound'); - } - - await record.update({ active: false }, { transaction }); - await record.destroy({ transaction }); - await transaction.commit(); - return true; - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts new file mode 100644 index 0000000..4b629df --- /dev/null +++ b/backend/src/services/content_catalog.ts @@ -0,0 +1,192 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import { resolvePagination } from '@/shared/constants/pagination'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { hasRoleAccess } from '@/services/shared/access'; +import { CONTENT_CATALOG_MANAGER_ROLE_NAMES } from '@/shared/constants/content-catalog'; +import type { ContentCatalog } from '@/db/models/content_catalog'; +import type { CurrentUser } from '@/db/api/types'; + +interface ContentCatalogInput { + content_type?: unknown; + payload?: unknown; + active?: boolean; + importHash?: string | null; +} + +function toContentCatalogDto(record: ContentCatalog) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + content_type: plain.content_type, + payload: plain.payload, + updatedAt: plain.updatedAt, + }; +} + +function assertCanManageContentCatalog(currentUser?: CurrentUser): void { + if (hasRoleAccess(currentUser, CONTENT_CATALOG_MANAGER_ROLE_NAMES)) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidContentType(contentType: unknown): string { + if (typeof contentType !== 'string' || contentType.trim().length === 0) { + throw new ValidationError(); + } + + return contentType.trim(); +} + +function assertValidPayload(payload: unknown): unknown { + if (payload === undefined) { + throw new ValidationError(); + } + + return payload; +} + +class ContentCatalogService { + static async list( + filter: { limit?: number | string; page?: number | string } = {}, + currentUser?: CurrentUser, + ) { + assertCanManageContentCatalog(currentUser); + + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const result = await db.content_catalog.findAndCountAll({ + order: [['content_type', 'asc']], + limit, + offset, + }); + + return { + rows: result.rows.map(toContentCatalogDto), + count: result.count, + }; + } + + static async findByType(contentType: unknown) { + const record = await db.content_catalog.findOne({ + where: { + content_type: assertValidContentType(contentType), + active: true, + }, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + return toContentCatalogDto(record); + } + + static async findManagedByType(contentType: unknown, currentUser?: CurrentUser) { + assertCanManageContentCatalog(currentUser); + + return this.findByType(contentType); + } + + static async create(data: ContentCatalogInput, currentUser?: CurrentUser) { + assertCanManageContentCatalog(currentUser); + + const contentType = assertValidContentType(data?.content_type); + const payload = assertValidPayload(data?.payload); + + const existingRecord = await db.content_catalog.findOne({ + where: { content_type: contentType }, + paranoid: false, + }); + + if (existingRecord && !existingRecord.deletedAt) { + throw new ValidationError(); + } + + return withTransaction(async (transaction) => { + let record: ContentCatalog; + if (existingRecord) { + await existingRecord.restore({ transaction }); + record = await existingRecord.update( + { + payload, + active: data.active !== false, + importHash: data.importHash || null, + }, + { transaction }, + ); + } else { + record = await db.content_catalog.create( + { + content_type: contentType, + payload, + active: data.active !== false, + importHash: data.importHash || null, + }, + { transaction }, + ); + } + + return toContentCatalogDto(record); + }); + } + + static async update( + contentType: unknown, + data: ContentCatalogInput, + currentUser?: CurrentUser, + ) { + assertCanManageContentCatalog(currentUser); + + const normalizedContentType = assertValidContentType(contentType); + const payload = assertValidPayload(data?.payload); + + return withTransaction(async (transaction) => { + const record = await db.content_catalog.findOne({ + where: { content_type: normalizedContentType }, + transaction, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + await record.update( + { + payload, + active: data.active !== false, + }, + { transaction }, + ); + + return toContentCatalogDto(record); + }); + } + + static async delete(contentType: unknown, currentUser?: CurrentUser) { + assertCanManageContentCatalog(currentUser); + + const normalizedContentType = assertValidContentType(contentType); + + return withTransaction(async (transaction) => { + const record = await db.content_catalog.findOne({ + where: { content_type: normalizedContentType }, + transaction, + }); + + if (!record) { + throw new ValidationError('contentCatalogNotFound'); + } + + await record.update({ active: false }, { transaction }); + await record.destroy({ transaction }); + return true; + }); + } +} + +export default ContentCatalogService; diff --git a/backend/src/services/documents.js b/backend/src/services/documents.js deleted file mode 100644 index cf72c9f..0000000 --- a/backend/src/services/documents.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const DocumentsDBApi = require('../db/api/documents'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class DocumentsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await DocumentsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await DocumentsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let documents = await DocumentsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!documents) { - throw new ValidationError( - 'documentsNotFound', - ); - } - - const updatedDocuments = await DocumentsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedDocuments; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await DocumentsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await DocumentsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/documents.ts b/backend/src/services/documents.ts new file mode 100644 index 0000000..8c86e26 --- /dev/null +++ b/backend/src/services/documents.ts @@ -0,0 +1,156 @@ +import { PassThrough } from 'stream'; +import csv from 'csv-parser'; +import db from '@/db/models'; +import DbApi from '@/db/api/documents'; +import ValidationError from '@/shared/errors/validation'; +import type { CurrentUser } from '@/db/api/types'; +import type { Documents } from '@/db/models/documents'; + +type CreateData = Parameters[0]; +type UpdateData = Parameters[1]; +type ListFilter = Parameters[0]; +type BulkRow = Parameters[0][number]; + +/** The document DTO exposed to the frontend — only the contract fields, without + * importHash/deletedAt or eager-loaded relations. */ +export function toDocumentDto(record: Documents) { + const plain = record.get({ plain: true }); + return { + id: plain.id, + entity_type: plain.entity_type, + entity_reference: plain.entity_reference, + name: plain.name, + category: plain.category, + uploaded_at: plain.uploaded_at, + notes: plain.notes, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +/** Parses an uploaded CSV buffer into bulk-import rows. */ +function parseCsvRows(fileBuffer: Buffer): Promise { + const bufferStream = new PassThrough(); + const results: BulkRow[] = []; + bufferStream.end(Buffer.from(fileBuffer)); + + return new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (row: BulkRow) => results.push(row)) + .on('end', () => resolve(results)) + .on('error', reject); + }); +} + +class DocumentsService { + static async create(data: CreateData, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + const created = await DbApi.create(data, { currentUser, transaction }); + await transaction.commit(); + return toDocumentDto(created); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(fileBuffer: Buffer, currentUser?: CurrentUser) { + const rows = await parseCsvRows(fileBuffer); + const transaction = await db.sequelize.transaction(); + try { + await DbApi.bulkImport(rows, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser, + }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data: UpdateData, id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + const updated = await DbApi.update(id, data, { currentUser, transaction }); + + if (!updated) { + throw new ValidationError('documentsNotFound'); + } + + await transaction.commit(); + return toDocumentDto(updated); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids: string[], currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DbApi.deleteByIds(ids, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DbApi.remove(id, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static list( + filter: ListFilter, + globalAccess: boolean, + currentUser?: CurrentUser, + ) { + return DbApi.findAll(filter, globalAccess, { currentUser }); + } + + static count( + filter: ListFilter, + globalAccess: boolean, + currentUser?: CurrentUser, + ) { + return DbApi.findAll(filter, globalAccess, { countOnly: true, currentUser }); + } + + static autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ) { + return DbApi.findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ); + } + + static findById(id: string) { + return DbApi.findBy({ id }); + } +} + +export default DocumentsService; diff --git a/backend/src/services/email/index.js b/backend/src/services/email/index.ts similarity index 59% rename from backend/src/services/email/index.js rename to backend/src/services/email/index.ts index bc97a3d..b3149aa 100644 --- a/backend/src/services/email/index.js +++ b/backend/src/services/email/index.ts @@ -1,9 +1,17 @@ -const config = require('../../config'); -const assert = require('assert'); -const nodemailer = require('nodemailer'); +import assert from 'assert'; +import nodemailer, { type SendMailOptions } from 'nodemailer'; +import config from '@/shared/config'; -module.exports = class EmailSender { - constructor(email) { +interface EmailMessage { + to: string; + subject: string; + html: () => Promise; +} + +class EmailSender { + email: EmailMessage; + + constructor(email: EmailMessage) { this.email = email; } @@ -17,7 +25,7 @@ module.exports = class EmailSender { const transporter = nodemailer.createTransport(this.transportConfig); - const mailOptions = { + const mailOptions: SendMailOptions = { from: this.from, to: this.email.to, subject: this.email.subject, @@ -30,8 +38,8 @@ module.exports = class EmailSender { return transporter.sendMail(mailOptions); } - static get isConfigured() { - return !!config.email?.auth?.pass && !!config.email?.auth?.user; + static get isConfigured(): boolean { + return Boolean(config.email?.auth?.pass) && Boolean(config.email?.auth?.user); } get transportConfig() { @@ -41,4 +49,6 @@ module.exports = class EmailSender { get from() { return config.email.from; } -}; +} + +export default EmailSender; diff --git a/backend/src/services/email/list/addressVerification.js b/backend/src/services/email/list/addressVerification.js deleted file mode 100644 index 695e199..0000000 --- a/backend/src/services/email/list/addressVerification.js +++ /dev/null @@ -1,38 +0,0 @@ -const { getNotification } = require('../../notifications/helpers'); -const fs = require('fs').promises; -const path = require('path'); - -module.exports = class EmailAddressVerificationEmail { - constructor(to, link) { - this.to = to; - this.link = link; - } - - get subject() { - return getNotification( - 'emails.emailAddressVerification.subject', - getNotification('app.title'), - ); - } - - async html() { - try { - const templatePath = path.join(__dirname, '../../email/htmlTemplates/addressVerification/emailAddressVerification.html'); - - const template = await fs.readFile(templatePath, 'utf8'); - - const appTitle = getNotification('app.title'); - const signupUrl = this.link; - - let html = template.replace(/{appTitle}/g, appTitle) - .replace(/{signupUrl}/g, signupUrl) - .replace(/{to}/g, this.to); - - return html; - } catch (error) { - console.error('Error generating invitation email HTML:', error); - throw error; - } - } - -}; diff --git a/backend/src/services/email/list/addressVerification.ts b/backend/src/services/email/list/addressVerification.ts new file mode 100644 index 0000000..40739d3 --- /dev/null +++ b/backend/src/services/email/list/addressVerification.ts @@ -0,0 +1,50 @@ +import logger from '@/shared/logger'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { getNotification } from '@/shared/notifications/helpers'; + +const __dirname = import.meta.dirname; + +class EmailAddressVerificationEmail { + to: string; + link: string; + + constructor(to: string, link: string) { + this.to = to; + this.link = link; + } + + get subject(): string { + return getNotification( + 'emails.emailAddressVerification.subject', + getNotification('app.title'), + ); + } + + async html(): Promise { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/addressVerification/emailAddressVerification.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = this.link; + + return template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + } catch (error) { + logger.error( + 'Error generating address verification email HTML:', + error, + ); + throw error; + } + } +} + +export default EmailAddressVerificationEmail; diff --git a/backend/src/services/email/list/invitation.js b/backend/src/services/email/list/invitation.js deleted file mode 100644 index 928c537..0000000 --- a/backend/src/services/email/list/invitation.js +++ /dev/null @@ -1,37 +0,0 @@ -const fs = require('fs').promises; -const path = require('path'); -const { getNotification } = require('../../notifications/helpers'); - -module.exports = class InvitationEmail { - constructor(to, host) { - this.to = to; - this.host = host; - } - - get subject() { - return getNotification( - 'emails.invitation.subject', - getNotification('app.title'), - ); - } - - async html() { - try { - const templatePath = path.join(__dirname, '../../email/htmlTemplates/invitation/invitationTemplate.html'); - - const template = await fs.readFile(templatePath, 'utf8'); - - const appTitle = getNotification('app.title'); - const signupUrl = `${this.host}&invitation=true`; - - let html = template.replace(/{appTitle}/g, appTitle) - .replace(/{signupUrl}/g, signupUrl) - .replace(/{to}/g, this.to); - - return html; - } catch (error) { - console.error('Error generating invitation email HTML:', error); - throw error; - } - } -}; \ No newline at end of file diff --git a/backend/src/services/email/list/invitation.ts b/backend/src/services/email/list/invitation.ts new file mode 100644 index 0000000..e8ac0d7 --- /dev/null +++ b/backend/src/services/email/list/invitation.ts @@ -0,0 +1,47 @@ +import logger from '@/shared/logger'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { getNotification } from '@/shared/notifications/helpers'; + +const __dirname = import.meta.dirname; + +class InvitationEmail { + to: string; + host: string; + + constructor(to: string, host: string) { + this.to = to; + this.host = host; + } + + get subject(): string { + return getNotification( + 'emails.invitation.subject', + getNotification('app.title'), + ); + } + + async html(): Promise { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/invitation/invitationTemplate.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const signupUrl = `${this.host}&invitation=true`; + + return template + .replace(/{appTitle}/g, appTitle) + .replace(/{signupUrl}/g, signupUrl) + .replace(/{to}/g, this.to); + } catch (error) { + logger.error('Error generating invitation email HTML:', error); + throw error; + } + } +} + +export default InvitationEmail; diff --git a/backend/src/services/email/list/passwordReset.js b/backend/src/services/email/list/passwordReset.js deleted file mode 100644 index c1fd105..0000000 --- a/backend/src/services/email/list/passwordReset.js +++ /dev/null @@ -1,38 +0,0 @@ -const { getNotification } = require('../../notifications/helpers'); -const path = require("path"); -const {promises: fs} = require("fs"); - -module.exports = class PasswordResetEmail { - constructor(to, link) { - this.to = to; - this.link = link; - } - - get subject() { - return getNotification( - 'emails.passwordReset.subject', - getNotification('app.title'), - ); - } - - async html() { - try { - const templatePath = path.join(__dirname, '../../email/htmlTemplates/passwordReset/passwordResetEmail.html'); - - const template = await fs.readFile(templatePath, 'utf8'); - - const appTitle = getNotification('app.title'); - const resetUrl = this.link; - const accountName = this.to; - - let html = template.replace(/{appTitle}/g, appTitle) - .replace(/{resetUrl}/g, resetUrl) - .replace(/{accountName}/g, accountName); - - return html; - } catch (error) { - console.error('Error generating invitation email HTML:', error); - throw error; - } - } -}; diff --git a/backend/src/services/email/list/passwordReset.ts b/backend/src/services/email/list/passwordReset.ts new file mode 100644 index 0000000..2b42415 --- /dev/null +++ b/backend/src/services/email/list/passwordReset.ts @@ -0,0 +1,48 @@ +import logger from '@/shared/logger'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { getNotification } from '@/shared/notifications/helpers'; + +const __dirname = import.meta.dirname; + +class PasswordResetEmail { + to: string; + link: string; + + constructor(to: string, link: string) { + this.to = to; + this.link = link; + } + + get subject(): string { + return getNotification( + 'emails.passwordReset.subject', + getNotification('app.title'), + ); + } + + async html(): Promise { + try { + const templatePath = path.join( + __dirname, + '../../email/htmlTemplates/passwordReset/passwordResetEmail.html', + ); + + const template = await fs.readFile(templatePath, 'utf8'); + + const appTitle = getNotification('app.title'); + const resetUrl = this.link; + const accountName = this.to; + + return template + .replace(/{appTitle}/g, appTitle) + .replace(/{resetUrl}/g, resetUrl) + .replace(/{accountName}/g, accountName); + } catch (error) { + logger.error('Error generating password reset email HTML:', error); + throw error; + } + } +} + +export default PasswordResetEmail; diff --git a/backend/src/services/fee_plans.js b/backend/src/services/fee_plans.js deleted file mode 100644 index 0516fec..0000000 --- a/backend/src/services/fee_plans.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Fee_plansDBApi = require('../db/api/fee_plans'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Fee_plansService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Fee_plansDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Fee_plansDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let fee_plans = await Fee_plansDBApi.findBy( - {id}, - {transaction}, - ); - - if (!fee_plans) { - throw new ValidationError( - 'fee_plansNotFound', - ); - } - - const updatedFee_plans = await Fee_plansDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedFee_plans; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Fee_plansDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Fee_plansDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/fee_plans.ts b/backend/src/services/fee_plans.ts new file mode 100644 index 0000000..156775e --- /dev/null +++ b/backend/src/services/fee_plans.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/fee_plans'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'fee_plansNotFound' }); diff --git a/backend/src/services/file.js b/backend/src/services/file.js deleted file mode 100644 index 597be30..0000000 --- a/backend/src/services/file.js +++ /dev/null @@ -1,213 +0,0 @@ -const formidable = require('formidable'); -const fs = require('fs'); -const config = require('../config'); -const path = require('path'); -const { format } = require("util"); - -const ensureDirectoryExistence = (filePath) => { - const dirname = path.dirname(filePath); - - if (fs.existsSync(dirname)) { - return true; - } - - ensureDirectoryExistence(dirname); - fs.mkdirSync(dirname); -} - -const uploadLocal = ( - folder, - validations = { - entity: null, - maxFileSize: null, - folderIncludesAuthenticationUid: false, - }, -) => { - return (req, res) => { - if (!req.currentUser) { - res.sendStatus(403); - return; - } - - if ( - validations.entity - ) { - res.sendStatus(403); - return; - } - - if (validations.folderIncludesAuthenticationUid) { - folder = folder.replace( - ':userId', - req.currentUser.authenticationUid, - ); - if ( - !req.currentUser.authenticationUid || - !folder.includes(req.currentUser.authenticationUid) - ) { - res.sendStatus(403); - return; - } - } - - const form = new formidable.IncomingForm(); - form.uploadDir = config.uploadDir; - - if (validations && validations.maxFileSize) { - form.maxFileSize = validations.maxFileSize; - } - - form.parse(req, function (err, fields, files) { - const filename = String(fields.filename); - const fileTempUrl = files.file.path; - - if (!filename) { - fs.unlinkSync(fileTempUrl); - res.sendStatus(500); - return; - } - - const privateUrl = path.join( - form.uploadDir, - folder, - filename, - ); - ensureDirectoryExistence(privateUrl); - fs.renameSync(fileTempUrl, privateUrl); - res.sendStatus(200); - }); - - form.on('error', function (err) { - res.status(500).send(err); - }); - } -} - -const downloadLocal = async (req, res) => { - const privateUrl = req.query.privateUrl; - if (!privateUrl) { - return res.sendStatus(404); - } - res.download(path.join(config.uploadDir, privateUrl)); -} - -const initGCloud = () => { - const processFile = require("../middlewares/upload"); - const { Storage } = require("@google-cloud/storage"); - - const crypto = require('crypto') - const hash = config.gcloud.hash - - const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n"); - - const storage = new Storage({ - projectId: process.env.GC_PROJECT_ID, - credentials: { - client_email: process.env.GC_CLIENT_EMAIL, - private_key: privateKey - } - }); - - const bucket = storage.bucket(config.gcloud.bucket); - return {hash, bucket, processFile}; -} - -const uploadGCloud = async (folder, req, res) => { - try { - const {hash, bucket, processFile} = initGCloud(); - await processFile(req, res); - let buffer = await req.file.buffer; - let filename = await req.body.filename; - - if (!req.file) { - return res.status(400).send({ message: "Please upload a file!" }); - } - - let path = `${hash}/${folder}/${filename}`; - let blob = bucket.file(path); - - console.log(path); - - const blobStream = blob.createWriteStream({ - resumable: false, - }); - - blobStream.on("error", (err) => { - console.log('Upload error'); - console.log(err.message); - res.status(500).send({ message: err.message }); - }); - - console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); - - blobStream.on("finish", async (data) => { - const publicUrl = format( - `https://storage.googleapis.com/${bucket.name}/${blob.name}` - ); - - res.status(200).send({ - message: "Uploaded the file successfully: " + path, - url: publicUrl, - }); - }); - - blobStream.end(buffer) - } catch (err) { - console.log(err); - - res.status(500).send({ - message: `Could not upload the file. ${err}` - }); - } -} - -const downloadGCloud = async (req, res) => { - try { - const {hash, bucket, processFile} = initGCloud(); - - const privateUrl = await req.query.privateUrl; - const filePath = `${hash}/${privateUrl}`; - const file = bucket.file(filePath) - const fileExists = await file.exists(); - - if (fileExists[0]) { - const stream = file.createReadStream(); - stream.pipe(res); - } - else { - res.status(404).send({ - message: "Could not download the file. " + err, - }); - } - } catch (err) { - res.status(404).send({ - message: "Could not download the file. " + err, - }); - } -} - -const deleteGCloud = async (privateUrl) => { - try { - const {hash, bucket, processFile} = initGCloud(); - const filePath = `${hash}/${privateUrl}`; - - const file = bucket.file(filePath) - const fileExists = await file.exists(); - - if (fileExists[0]) { - file.delete(); - } - } catch (err) { - console.log(`Cannot find the file ${privateUrl}`); - } -} - -module.exports = { - initGCloud, - uploadLocal, - downloadLocal, - deleteGCloud, - uploadGCloud, - downloadGCloud -} - diff --git a/backend/src/services/file.ts b/backend/src/services/file.ts new file mode 100644 index 0000000..690e5b8 --- /dev/null +++ b/backend/src/services/file.ts @@ -0,0 +1,169 @@ +import fs from 'fs'; +import path from 'path'; +import { format } from 'util'; +import { Storage } from '@google-cloud/storage'; +import type { Request, Response } from 'express'; +import config from '@/shared/config'; +import processFile from '@/middlewares/upload'; + +interface UploadValidations { + entity?: string | null; + maxFileSize?: number | null; + folderIncludesAuthenticationUid?: boolean; +} + +function ensureDirectoryExistence(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function initGCloud() { + const hash = config.gcloud.hash; + const privateKey = (process.env.GC_PRIVATE_KEY || '').replace(/\\\n/g, '\n'); + + const storage = new Storage({ + projectId: process.env.GC_PROJECT_ID, + credentials: { + client_email: process.env.GC_CLIENT_EMAIL, + private_key: privateKey, + }, + }); + + const bucket = storage.bucket(config.gcloud.bucket); + return { hash, bucket, processFile }; +} + +/** + * Local-disk upload (development only). Uses the shared multer middleware + * (`processFile`) to read the file, then writes it under the upload dir. + */ +function uploadLocal(folder: string, validations: UploadValidations = {}) { + return async (req: Request, res: Response): Promise => { + if (!req.currentUser) { + res.sendStatus(403); + return; + } + + if (validations.entity) { + res.sendStatus(403); + return; + } + + try { + await processFile(req, res); + + if (!req.file) { + res.sendStatus(400); + return; + } + + const filename = String(req.body?.filename ?? ''); + if (!filename) { + res.sendStatus(500); + return; + } + + const privateUrl = path.join(config.uploadDir, folder, filename); + ensureDirectoryExistence(privateUrl); + fs.writeFileSync(privateUrl, req.file.buffer); + res.sendStatus(200); + } catch (error) { + res.status(500).send(String(error)); + } + }; +} + +function downloadLocal(req: Request, res: Response): void { + const privateUrl = req.query.privateUrl; + if (!privateUrl) { + res.sendStatus(404); + return; + } + res.download(path.join(config.uploadDir, String(privateUrl))); +} + +async function uploadGCloud( + folder: string, + req: Request, + res: Response, +): Promise { + try { + const { hash, bucket } = initGCloud(); + await processFile(req, res); + + if (!req.file) { + res.status(400).send({ message: 'Please upload a file!' }); + return; + } + + const buffer = req.file.buffer; + const filename = String(req.body?.filename ?? ''); + const objectPath = `${hash}/${folder}/${filename}`; + const blob = bucket.file(objectPath); + + const blobStream = blob.createWriteStream({ resumable: false }); + + blobStream.on('error', (err: Error) => { + res.status(500).send({ message: err.message }); + }); + + blobStream.on('finish', () => { + const publicUrl = format( + `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + ); + res.status(200).send({ + message: 'Uploaded the file successfully: ' + objectPath, + url: publicUrl, + }); + }); + + blobStream.end(buffer); + } catch (error) { + res.status(500).send({ message: `Could not upload the file. ${error}` }); + } +} + +async function downloadGCloud(req: Request, res: Response): Promise { + try { + const { hash, bucket } = initGCloud(); + + const privateUrl = String(req.query.privateUrl ?? ''); + const filePath = `${hash}/${privateUrl}`; + const file = bucket.file(filePath); + const [fileExists] = await file.exists(); + + if (fileExists) { + file.createReadStream().pipe(res); + } else { + res.status(404).send({ message: 'Could not download the file.' }); + } + } catch (error) { + res.status(404).send({ + message: `Could not download the file. ${error}`, + }); + } +} + +async function deleteGCloud(privateUrl: string): Promise { + try { + const { hash, bucket } = initGCloud(); + const filePath = `${hash}/${privateUrl}`; + + const file = bucket.file(filePath); + const [fileExists] = await file.exists(); + + if (fileExists) { + await file.delete(); + } + } catch { + console.log(`Cannot find the file ${privateUrl}`); + } +} + +export default { + initGCloud, + uploadLocal, + downloadLocal, + deleteGCloud, + uploadGCloud, + downloadGCloud, +}; diff --git a/backend/src/services/frame_entries.js b/backend/src/services/frame_entries.js deleted file mode 100644 index f403d5e..0000000 --- a/backend/src/services/frame_entries.js +++ /dev/null @@ -1,184 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { FRAME_EDITOR_ROLE_NAMES } = require('../constants/frame'); - -const REQUIRED_FIELDS = [ - 'week_of', - 'posted_date', - 'formal', - 'recognition', - 'application', - 'management', - 'emotional', - 'author', -]; - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id || currentUser?.organizationsId || null; -} - -function getCampusId(currentUser, data) { - if (data.campusId) { - return data.campusId; - } - - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return null; -} - -function assertCanEdit(currentUser) { - const roleName = currentUser?.app_role?.name; - const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; - - if (hasGlobalAccess || FRAME_EDITOR_ROLE_NAMES.includes(roleName)) { - return; - } - - throw new ForbiddenError(); -} - -function assertValidFrameEntry(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - const hasMissingField = REQUIRED_FIELDS.some((field) => { - const value = data[field]; - return typeof value !== 'string' || value.trim().length === 0; - }); - - if (hasMissingField) { - throw new ValidationError(); - } -} - -function toDto(entry) { - const plainEntry = typeof entry.get === 'function' - ? entry.get({ plain: true }) - : entry; - - return { - id: plainEntry.id, - week_of: plainEntry.week_of, - posted_date: plainEntry.posted_date, - formal: plainEntry.formal, - recognition: plainEntry.recognition, - application: plainEntry.application, - management: plainEntry.management, - emotional: plainEntry.emotional, - author: plainEntry.author, - organizationId: plainEntry.organizationId, - campusId: plainEntry.campusId, - createdAt: plainEntry.createdAt, - updatedAt: plainEntry.updatedAt, - }; -} - -module.exports = class FrameEntriesService { - static async list(currentUser) { - const organizationId = getOrganizationId(currentUser); - - if (!organizationId) { - throw new ForbiddenError(); - } - - const result = await db.frame_entries.findAndCountAll({ - where: { organizationId }, - order: [['createdAt', 'desc']], - }); - - return { - rows: result.rows.map(toDto), - count: result.count, - }; - } - - static async create(data, currentUser) { - assertCanEdit(currentUser); - assertValidFrameEntry(data); - - const organizationId = getOrganizationId(currentUser); - - if (!organizationId) { - throw new ForbiddenError(); - } - - const transaction = await db.sequelize.transaction(); - - try { - const entry = await db.frame_entries.create( - { - week_of: data.week_of.trim(), - posted_date: data.posted_date.trim(), - formal: data.formal.trim(), - recognition: data.recognition.trim(), - application: data.application.trim(), - management: data.management.trim(), - emotional: data.emotional.trim(), - author: data.author.trim(), - organizationId, - campusId: getCampusId(currentUser, data), - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toDto(entry); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(id, data, currentUser) { - assertCanEdit(currentUser); - assertValidFrameEntry(data); - - const organizationId = getOrganizationId(currentUser); - - if (!organizationId) { - throw new ForbiddenError(); - } - - const transaction = await db.sequelize.transaction(); - - try { - const entry = await db.frame_entries.findOne({ - where: { id, organizationId }, - transaction, - }); - - if (!entry) { - throw new ValidationError(); - } - - await entry.update( - { - week_of: data.week_of.trim(), - posted_date: data.posted_date.trim(), - formal: data.formal.trim(), - recognition: data.recognition.trim(), - application: data.application.trim(), - management: data.management.trim(), - emotional: data.emotional.trim(), - author: data.author.trim(), - campusId: getCampusId(currentUser, data), - updatedById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toDto(entry); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; diff --git a/backend/src/services/frame_entries.ts b/backend/src/services/frame_entries.ts new file mode 100644 index 0000000..4ad07c3 --- /dev/null +++ b/backend/src/services/frame_entries.ts @@ -0,0 +1,191 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import { resolvePagination } from '@/shared/constants/pagination'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { + getOrganizationId, + getOrganizationIdOrGlobal, + hasRoleAccess, +} from '@/services/shared/access'; +import { FRAME_EDITOR_ROLE_NAMES } from '@/shared/constants/frame'; +import type { FrameEntries } from '@/db/models/frame_entries'; +import type { CurrentUser } from '@/db/api/types'; + +interface FrameEntryInput { + week_of: string; + posted_date: string; + formal: string; + recognition: string; + application: string; + management: string; + emotional: string; + author: string; + campusId?: string | null; +} + +const REQUIRED_FIELDS = [ + 'week_of', + 'posted_date', + 'formal', + 'recognition', + 'application', + 'management', + 'emotional', + 'author', +] as const; + +function getCampusId( + currentUser: CurrentUser | undefined, + data: FrameEntryInput, +): string | null { + if (data.campusId) { + return data.campusId; + } + + const staff = currentUser?.staff_user; + if (Array.isArray(staff) && staff[0]?.campusId) { + return staff[0].campusId; + } + + return null; +} + +function assertCanEdit(currentUser?: CurrentUser): void { + if (hasRoleAccess(currentUser, FRAME_EDITOR_ROLE_NAMES)) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidFrameEntry(data: FrameEntryInput): void { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const hasMissingField = REQUIRED_FIELDS.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + if (hasMissingField) { + throw new ValidationError(); + } +} + +function toDto(entry: FrameEntries) { + const plain = entry.get({ plain: true }); + + return { + id: plain.id, + week_of: plain.week_of, + posted_date: plain.posted_date, + formal: plain.formal, + recognition: plain.recognition, + application: plain.application, + management: plain.management, + emotional: plain.emotional, + author: plain.author, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class FrameEntriesService { + static async list( + filter: { limit?: number | string; page?: number | string } = {}, + currentUser?: CurrentUser, + ) { + const organizationId = getOrganizationIdOrGlobal(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const where = organizationId ? { organizationId } : {}; + + const result = await db.frame_entries.findAndCountAll({ + where, + order: [['createdAt', 'desc']], + limit, + offset, + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data: FrameEntryInput, currentUser?: CurrentUser) { + assertCanEdit(currentUser); + assertValidFrameEntry(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + + return withTransaction(async (transaction) => { + const entry = await db.frame_entries.create( + { + week_of: data.week_of.trim(), + posted_date: data.posted_date.trim(), + formal: data.formal.trim(), + recognition: data.recognition.trim(), + application: data.application.trim(), + management: data.management.trim(), + emotional: data.emotional.trim(), + author: data.author.trim(), + organizationId, + campusId: getCampusId(currentUser, data), + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + return toDto(entry); + }); + } + + static async update( + id: string, + data: FrameEntryInput, + currentUser?: CurrentUser, + ) { + assertCanEdit(currentUser); + assertValidFrameEntry(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const where = organizationId ? { id, organizationId } : { id }; + + return withTransaction(async (transaction) => { + const entry = await db.frame_entries.findOne({ + where, + transaction, + }); + + if (!entry) { + throw new ValidationError(); + } + + await entry.update( + { + week_of: data.week_of.trim(), + posted_date: data.posted_date.trim(), + formal: data.formal.trim(), + recognition: data.recognition.trim(), + application: data.application.trim(), + management: data.management.trim(), + emotional: data.emotional.trim(), + author: data.author.trim(), + campusId: getCampusId(currentUser, data), + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + return toDto(entry); + }); + } +} + +export default FrameEntriesService; diff --git a/backend/src/services/grades.js b/backend/src/services/grades.js deleted file mode 100644 index 2b9bdaa..0000000 --- a/backend/src/services/grades.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const GradesDBApi = require('../db/api/grades'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class GradesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await GradesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await GradesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let grades = await GradesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!grades) { - throw new ValidationError( - 'gradesNotFound', - ); - } - - const updatedGrades = await GradesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedGrades; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await GradesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await GradesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/grades.ts b/backend/src/services/grades.ts new file mode 100644 index 0000000..bb07e64 --- /dev/null +++ b/backend/src/services/grades.ts @@ -0,0 +1,8 @@ +import GradesDBApi from '@/db/api/grades'; +import { createCrudService } from '@/services/shared/crud-service'; + +const GradesService = createCrudService(GradesDBApi, { + notFoundCode: 'gradesNotFound', +}); + +export default GradesService; diff --git a/backend/src/services/guardians.js b/backend/src/services/guardians.js deleted file mode 100644 index 956b88b..0000000 --- a/backend/src/services/guardians.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const GuardiansDBApi = require('../db/api/guardians'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class GuardiansService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await GuardiansDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await GuardiansDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let guardians = await GuardiansDBApi.findBy( - {id}, - {transaction}, - ); - - if (!guardians) { - throw new ValidationError( - 'guardiansNotFound', - ); - } - - const updatedGuardians = await GuardiansDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedGuardians; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await GuardiansDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await GuardiansDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/guardians.ts b/backend/src/services/guardians.ts new file mode 100644 index 0000000..e96c1e8 --- /dev/null +++ b/backend/src/services/guardians.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/guardians'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'guardiansNotFound' }); diff --git a/backend/src/services/invoices.js b/backend/src/services/invoices.js deleted file mode 100644 index b0356af..0000000 --- a/backend/src/services/invoices.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const InvoicesDBApi = require('../db/api/invoices'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class InvoicesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await InvoicesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await InvoicesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let invoices = await InvoicesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!invoices) { - throw new ValidationError( - 'invoicesNotFound', - ); - } - - const updatedInvoices = await InvoicesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedInvoices; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await InvoicesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await InvoicesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/invoices.ts b/backend/src/services/invoices.ts new file mode 100644 index 0000000..57e78e6 --- /dev/null +++ b/backend/src/services/invoices.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/invoices'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'invoicesNotFound' }); diff --git a/backend/src/services/message_recipients.js b/backend/src/services/message_recipients.js deleted file mode 100644 index e63e60c..0000000 --- a/backend/src/services/message_recipients.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Message_recipientsDBApi = require('../db/api/message_recipients'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Message_recipientsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Message_recipientsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Message_recipientsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let message_recipients = await Message_recipientsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!message_recipients) { - throw new ValidationError( - 'message_recipientsNotFound', - ); - } - - const updatedMessage_recipients = await Message_recipientsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedMessage_recipients; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Message_recipientsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Message_recipientsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/message_recipients.ts b/backend/src/services/message_recipients.ts new file mode 100644 index 0000000..568cdaa --- /dev/null +++ b/backend/src/services/message_recipients.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/message_recipients'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'message_recipientsNotFound' }); diff --git a/backend/src/services/messages.js b/backend/src/services/messages.js deleted file mode 100644 index c2d5dd8..0000000 --- a/backend/src/services/messages.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const MessagesDBApi = require('../db/api/messages'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class MessagesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await MessagesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await MessagesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let messages = await MessagesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!messages) { - throw new ValidationError( - 'messagesNotFound', - ); - } - - const updatedMessages = await MessagesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedMessages; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await MessagesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await MessagesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/messages.ts b/backend/src/services/messages.ts new file mode 100644 index 0000000..220bca6 --- /dev/null +++ b/backend/src/services/messages.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/messages'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'messagesNotFound' }); diff --git a/backend/src/services/notifications/errors/forbidden.js b/backend/src/services/notifications/errors/forbidden.js deleted file mode 100644 index 33e5dc2..0000000 --- a/backend/src/services/notifications/errors/forbidden.js +++ /dev/null @@ -1,17 +0,0 @@ -const { getNotification, isNotification } = require('../helpers'); - -module.exports = class ForbiddenError extends Error { - constructor(messageCode) { - let message; - - if (messageCode && isNotification(messageCode)) { - message = getNotification(messageCode); - } - - message = - message || getNotification('errors.forbidden.message'); - - super(message); - this.code = 403; - } -}; diff --git a/backend/src/services/notifications/errors/validation.js b/backend/src/services/notifications/errors/validation.js deleted file mode 100644 index cf3130c..0000000 --- a/backend/src/services/notifications/errors/validation.js +++ /dev/null @@ -1,18 +0,0 @@ -const { getNotification, isNotification } = require('../helpers'); - -module.exports = class ValidationError extends Error { - constructor(messageCode) { - let message; - - if (messageCode && isNotification(messageCode)) { - message = getNotification(messageCode); - } - - message = - message || - getNotification('errors.validation.message'); - - super(message); - this.code = 400; - } -}; diff --git a/backend/src/services/notifications/helpers.js b/backend/src/services/notifications/helpers.js deleted file mode 100644 index b2f31fd..0000000 --- a/backend/src/services/notifications/helpers.js +++ /dev/null @@ -1,35 +0,0 @@ -const _get = require('lodash/get'); -const errors = require('./list'); - -function format(message, args) { - if (!message) { - return null; - } - - return message.replace(/{(\d+)}/g, function ( - match, - number, - ) { - return typeof args[number] != 'undefined' - ? args[number] - : match; - }); -} - -const isNotification = (key) => { - const message = _get(errors, key); - return !!message; -}; - -const getNotification = (key, ...args) => { - const message = _get(errors, key); - - if (!message) { - return key; - } - - return format(message, args); -}; - -exports.getNotification = getNotification; -exports.isNotification = isNotification; diff --git a/backend/src/services/openai.js b/backend/src/services/openai.js deleted file mode 100644 index 3793398..0000000 --- a/backend/src/services/openai.js +++ /dev/null @@ -1,80 +0,0 @@ -const axios = require('axios'); -const config = require('../config'); -const { LocalAIApi } = require('../ai/LocalAIApi'); - -const loadRoleService = () => { - try { - return require('./roles'); - } catch (error) { - console.error('Role service is missing. Advanced roles are required for this operation.', error); - const err = new Error('Role service is missing. Advanced roles are required for this operation.'); - err.originalError = error; - throw err; - } -}; - -module.exports = class OpenAiService { - static async getWidget(payload, userId, roleId) { - const RoleService = loadRoleService(); - const response = await axios.post( - `${config.flHost}/${config.project_uuid}/project_customization_widgets.json`, - payload, - ); - - if (response.status >= 200 && response.status < 300) { - const { widget_id } = await response.data; - await RoleService.addRoleInfo(roleId, userId, 'widgets', widget_id); - return widget_id; - } else { - console.error('=======error=======', response.data); - return { value: null, error: response.data }; - } - } - - static async askGpt(prompt) { - if (!prompt) { - return { - success: false, - error: 'Prompt is required' - }; - } - - const response = await LocalAIApi.createResponse( - { - input: [{ role: 'user', content: prompt }], - }, - { - poll_interval: 5, - poll_timeout: 300, - }, - ); - - if (response.success) { - let text = LocalAIApi.extractText(response); - if (!text) { - try { - const decoded = LocalAIApi.decodeJsonFromResponse(response); - text = JSON.stringify(decoded); - } catch (error) { - console.error('AI JSON decode failed:', error); - return { - success: false, - error: 'AI response parsing failed', - details: error.message || String(error), - }; - } - } - return { - success: true, - data: text, - }; - } - - console.error('AI proxy error:', response); - return { - success: false, - error: response.error || response.message || 'AI proxy error', - response, - }; - } -}; diff --git a/backend/src/services/organizations.js b/backend/src/services/organizations.js deleted file mode 100644 index 5dafbb9..0000000 --- a/backend/src/services/organizations.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const OrganizationsDBApi = require('../db/api/organizations'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class OrganizationsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await OrganizationsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await OrganizationsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let organizations = await OrganizationsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!organizations) { - throw new ValidationError( - 'organizationsNotFound', - ); - } - - const updatedOrganizations = await OrganizationsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedOrganizations; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await OrganizationsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await OrganizationsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/organizations.ts b/backend/src/services/organizations.ts new file mode 100644 index 0000000..97b9a16 --- /dev/null +++ b/backend/src/services/organizations.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/organizations'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'organizationsNotFound' }); diff --git a/backend/src/services/payments.js b/backend/src/services/payments.js deleted file mode 100644 index ef655fe..0000000 --- a/backend/src/services/payments.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const PaymentsDBApi = require('../db/api/payments'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class PaymentsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await PaymentsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await PaymentsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let payments = await PaymentsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!payments) { - throw new ValidationError( - 'paymentsNotFound', - ); - } - - const updatedPayments = await PaymentsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedPayments; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await PaymentsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await PaymentsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/payments.ts b/backend/src/services/payments.ts new file mode 100644 index 0000000..877b126 --- /dev/null +++ b/backend/src/services/payments.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/payments'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'paymentsNotFound' }); diff --git a/backend/src/services/permissions.js b/backend/src/services/permissions.js deleted file mode 100644 index e505d0c..0000000 --- a/backend/src/services/permissions.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const PermissionsDBApi = require('../db/api/permissions'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class PermissionsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await PermissionsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await PermissionsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let permissions = await PermissionsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!permissions) { - throw new ValidationError( - 'permissionsNotFound', - ); - } - - const updatedPermissions = await PermissionsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedPermissions; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await PermissionsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await PermissionsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/permissions.ts b/backend/src/services/permissions.ts new file mode 100644 index 0000000..42f87ec --- /dev/null +++ b/backend/src/services/permissions.ts @@ -0,0 +1,117 @@ +import { PassThrough } from 'stream'; +import csv from 'csv-parser'; +import db from '@/db/models'; +import DbApi from '@/db/api/permissions'; +import ValidationError from '@/shared/errors/validation'; +import type { CurrentUser } from '@/db/api/types'; + +type CreateData = Parameters[0]; +type UpdateData = Parameters[1]; +type ListFilter = Parameters[0]; +type BulkRow = Parameters[0][number]; + +/** Parses an uploaded CSV buffer into bulk-import rows. */ +function parseCsvRows(fileBuffer: Buffer): Promise { + const bufferStream = new PassThrough(); + const results: BulkRow[] = []; + bufferStream.end(Buffer.from(fileBuffer)); + + return new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (row: BulkRow) => results.push(row)) + .on('end', () => resolve(results)) + .on('error', reject); + }); +} + +class PermissionsService { + static async create(data: CreateData, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DbApi.create(data, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(fileBuffer: Buffer, currentUser?: CurrentUser) { + const rows = await parseCsvRows(fileBuffer); + const transaction = await db.sequelize.transaction(); + try { + await DbApi.bulkImport(rows, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser, + }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data: UpdateData, id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + const updated = await DbApi.update(id, data, { currentUser, transaction }); + + if (!updated) { + throw new ValidationError('permissionsNotFound'); + } + + await transaction.commit(); + return updated; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids: string[], currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DbApi.deleteByIds(ids, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await DbApi.remove(id, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static list(filter: ListFilter, currentUser?: CurrentUser) { + return DbApi.findAll(filter, { currentUser }); + } + + static count(filter: ListFilter, currentUser?: CurrentUser) { + return DbApi.findAll(filter, { countOnly: true, currentUser }); + } + + static autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + ) { + return DbApi.findAllAutocomplete(query, limit, offset); + } + + static findById(id: string) { + return DbApi.findBy({ id }); + } +} + +export default PermissionsService; diff --git a/backend/src/services/personality_quiz_results.js b/backend/src/services/personality_quiz_results.js deleted file mode 100644 index d6b0f49..0000000 --- a/backend/src/services/personality_quiz_results.js +++ /dev/null @@ -1,173 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { PERSONALITY_REPORT_ROLE_NAMES } = require('../constants/personality'); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id - || currentUser?.organization?.id - || currentUser?.organizationsId - || currentUser?.organizationId - || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return currentUser?.campusId || null; -} - -function assertAuthenticatedUser(currentUser) { - if (!currentUser?.id || !getOrganizationId(currentUser)) { - throw new ForbiddenError(); - } -} - -function canViewDistribution(currentUser) { - const roleName = currentUser?.app_role?.name; - return currentUser?.app_role?.globalAccess === true || PERSONALITY_REPORT_ROLE_NAMES.includes(roleName); -} - -function assertValidResult(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - if ( - typeof data.personality_type !== 'string' - || data.personality_type.trim().length === 0 - || !data.quiz_answers - || typeof data.quiz_answers !== 'object' - || Array.isArray(data.quiz_answers) - ) { - throw new ValidationError(); - } - - const answerValues = Object.values(data.quiz_answers); - if (!answerValues.every((value) => typeof value === 'string' && value.trim().length > 0)) { - throw new ValidationError(); - } -} - -function toDto(record) { - if (!record) { - return null; - } - - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - personality_type: plainRecord.personality_type, - quiz_answers: plainRecord.quiz_answers, - completed_at: plainRecord.completed_at, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - userId: plainRecord.userId, - createdById: plainRecord.createdById, - updatedById: plainRecord.updatedById, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -module.exports = class PersonalityQuizResultsService { - static async getCurrentUserResult(currentUser) { - assertAuthenticatedUser(currentUser); - - const record = await db.personality_quiz_results.findOne({ - where: { - organizationId: getOrganizationId(currentUser), - userId: currentUser.id, - }, - order: [['updatedAt', 'desc']], - }); - - return toDto(record); - } - - static async upsertCurrentUserResult(data, currentUser) { - assertAuthenticatedUser(currentUser); - assertValidResult(data); - - const where = { - organizationId: getOrganizationId(currentUser), - userId: currentUser.id, - }; - const transaction = await db.sequelize.transaction(); - - try { - const existing = await db.personality_quiz_results.findOne({ - where, - transaction, - }); - - const payload = { - personality_type: data.personality_type.trim().toUpperCase(), - quiz_answers: data.quiz_answers, - completed_at: new Date(), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - userId: currentUser.id, - updatedById: currentUser.id, - }; - - let saved; - if (existing) { - saved = await existing.update(payload, { transaction }); - } else { - saved = await db.personality_quiz_results.create( - { - ...payload, - createdById: currentUser.id, - }, - { transaction }, - ); - } - - await transaction.commit(); - return toDto(saved); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async distribution(filter, currentUser) { - assertAuthenticatedUser(currentUser); - - if (!canViewDistribution(currentUser)) { - throw new ForbiddenError(); - } - - const where = { - organizationId: getOrganizationId(currentUser), - }; - - if (filter.campusId) { - where.campusId = filter.campusId; - } - - const rows = await db.personality_quiz_results.findAll({ - attributes: [ - 'personality_type', - [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'], - ], - where, - group: ['personality_type'], - order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']], - }); - - return { - rows: rows.map((row) => ({ - type: row.get('personality_type'), - count: Number(row.get('count')), - })), - count: rows.length, - }; - } -}; diff --git a/backend/src/services/personality_quiz_results.ts b/backend/src/services/personality_quiz_results.ts new file mode 100644 index 0000000..8b90b71 --- /dev/null +++ b/backend/src/services/personality_quiz_results.ts @@ -0,0 +1,171 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { + getOrganizationIdOrGlobal, + getCampusId, + assertAuthenticatedTenantUser, + hasRoleAccess, +} from '@/services/shared/access'; +import { PERSONALITY_REPORT_ROLE_NAMES } from '@/shared/constants/personality'; +import type { PersonalityQuizResults } from '@/db/models/personality_quiz_results'; +import type { CurrentUser } from '@/db/api/types'; + +interface PersonalityInput { + personality_type: string; + quiz_answers: unknown; +} + +interface PersonalityFilter { + campusId?: string; +} + +function assertValidResult(data: PersonalityInput): void { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + if ( + typeof data.personality_type !== 'string' || + data.personality_type.trim().length === 0 || + !data.quiz_answers || + typeof data.quiz_answers !== 'object' || + Array.isArray(data.quiz_answers) + ) { + throw new ValidationError(); + } + + const answerValues = Object.values(data.quiz_answers); + if ( + !answerValues.every( + (value) => typeof value === 'string' && value.trim().length > 0, + ) + ) { + throw new ValidationError(); + } +} + +function toDto(record: PersonalityQuizResults | null) { + if (!record) { + return null; + } + + const plain = record.get({ plain: true }); + + return { + id: plain.id, + personality_type: plain.personality_type, + quiz_answers: plain.quiz_answers, + completed_at: plain.completed_at, + organizationId: plain.organizationId, + campusId: plain.campusId, + userId: plain.userId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class PersonalityQuizResultsService { + static async getCurrentUserResult(currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const record = await db.personality_quiz_results.findOne({ + where: { + ...orgFilter, + userId: currentUser?.id ?? null, + }, + order: [['updatedAt', 'desc']], + }); + + return toDto(record); + } + + static async upsertCurrentUserResult( + data: PersonalityInput, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + assertValidResult(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + const where = { + ...orgFilter, + userId: currentUser?.id ?? null, + }; + return withTransaction(async (transaction) => { + const existing = await db.personality_quiz_results.findOne({ + where, + transaction, + }); + + const payload = { + personality_type: data.personality_type.trim().toUpperCase(), + quiz_answers: data.quiz_answers, + completed_at: new Date(), + organizationId, + campusId: getCampusId(currentUser), + userId: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }; + + let saved: PersonalityQuizResults; + if (existing) { + saved = await existing.update(payload, { transaction }); + } else { + saved = await db.personality_quiz_results.create( + { + ...payload, + createdById: currentUser?.id ?? null, + }, + { transaction }, + ); + } + + return toDto(saved); + }); + } + + static async distribution( + filter: PersonalityFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + if (!hasRoleAccess(currentUser, PERSONALITY_REPORT_ROLE_NAMES)) { + throw new ForbiddenError(); + } + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const rows = await db.personality_quiz_results.findAll({ + attributes: [ + 'personality_type', + [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'], + ], + where: { + ...orgFilter, + ...(filter.campusId ? { campusId: filter.campusId } : {}), + }, + group: ['personality_type'], + order: [[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'desc']], + }); + + return { + rows: rows.map((row) => ({ + type: row.get('personality_type'), + count: Number(row.get('count')), + })), + count: rows.length, + }; + } +} + +export default PersonalityQuizResultsService; diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js deleted file mode 100644 index ec6186b..0000000 --- a/backend/src/services/roles.js +++ /dev/null @@ -1,399 +0,0 @@ -const db = require('../db/models'); -const RolesDBApi = require('../db/api/roles'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - -function buildWidgetResult(widget, queryResult, queryString) { - if (queryResult[0] && queryResult[0].length) { - const key = Object.keys(queryResult[0][0])[0]; - const value = widget.widget_type === 'scalar' ? queryResult[0][0][key] : queryResult[0]; - const widgetData = JSON.parse(widget.data); - return { ...widget, ...widgetData, value, query: queryString }; - } else { - return { ...widget, value: [], query: queryString }; - } -} - -async function executeQuery(queryString, currentUser) { - try { - return await db.sequelize.query(queryString, { - replacements: { organizationId: currentUser.organizationId }, - }); - } catch (e) { - console.log(e); - return []; - } -} - -function insertWhereConditions(queryString, whereConditions) { - if (!whereConditions) return queryString; - - const whereIndex = queryString.toLowerCase().indexOf('where'); - const groupByIndex = queryString.toLowerCase().indexOf('group by'); - const insertIndex = whereIndex === -1 - ? (groupByIndex !== -1 ? groupByIndex : queryString.length) - : whereIndex + 5; - - const prefix = queryString.substring(0, insertIndex); - const suffix = queryString.substring(insertIndex); - const conditionString = whereIndex === -1 ? ` WHERE ${whereConditions} ` : ` ${whereConditions} AND `; - - return `${prefix}${conditionString}${suffix}`; -} - -function constructWhereConditions(mainTable, currentUser, replacements) { - const { organizationId, app_role: { globalAccess } } = currentUser; - const tablesWithoutOrgId = ['permissions', 'roles']; - let whereConditions = ''; - - if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) { - whereConditions += `"${mainTable}"."organizationId" = :organizationId`; - replacements.organizationId = organizationId; - } - - whereConditions += whereConditions ? ' AND ' : ''; - whereConditions += `"${mainTable}"."deletedAt" IS NULL`; - - return whereConditions; -} - -function extractTableName(queryString) { - const tableNameRegex = /FROM\s+("?)([^"\s]+)\1\s*/i; - const match = tableNameRegex.exec(queryString); - return match ? match[2] : null; -} - -function buildQueryString(widget, currentUser) { - let queryString = widget?.query || ''; - const tableName = extractTableName(queryString); - const mainTable = JSON.parse(widget?.data)?.main_table || tableName; - const replacements = {}; - const whereConditions = constructWhereConditions(mainTable, currentUser, replacements); - queryString = insertWhereConditions(queryString, whereConditions); - console.log(queryString, 'queryString'); - return queryString; -} - -async function constructWidgetsResults(widgets, currentUser) { - const widgetsResults = []; - for (const widget of widgets) { - if (!widget) continue; - const queryString = buildQueryString(widget, currentUser); - const queryResult = await executeQuery(queryString, currentUser); - widgetsResults.push(buildWidgetResult(widget, queryResult, queryString)); - } - return widgetsResults; -} - -async function fetchWidgetsData(widgets) { - const widgetPromises = (widgets || []).map(widgetId => - axios.get(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${widgetId}.json`) - ); - const widgetResults = widgetPromises ? await Promise.allSettled(widgetPromises) : []; - return widgetResults - .filter(result => result.status === 'fulfilled') - .map(result => result.value.data); -} - -async function processWidgets(widgets, currentUser) { - const widgetData = await fetchWidgetsData(widgets); - return constructWidgetsResults(widgetData, currentUser); -} - -function parseCustomization(role) { - try { - return JSON.parse(role.role_customization || '{}'); - } catch (e) { - console.log(e); - return {}; - } -} - -async function findRole(roleId, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - const role = roleId - ? await RolesDBApi.findBy({ id: roleId }, { transaction }) - : await RolesDBApi.findBy({ name: 'User' }, { transaction }); - await transaction.commit(); - return role; - } catch (error) { - await transaction.rollback(); - throw error; - } -} - - -module.exports = class RolesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await RolesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await RolesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let roles = await RolesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!roles) { - throw new ValidationError( - 'rolesNotFound', - ); - } - - const updatedRoles = await RolesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedRoles; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await RolesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await RolesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - - static async addRoleInfo(roleId, userId, key, widgetId, currentUser) { - const regexExpForUuid = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi; - const widgetIdIsUUID = regexExpForUuid.test(widgetId); - - const transaction = await db.sequelize.transaction(); - let role; - if (roleId) { - role = await RolesDBApi.findBy({ id: roleId }, { transaction }); - } else { - role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); - } - - if (!role) { - throw new ValidationError('rolesNotFound'); - } - - try { - let customization = {}; - try { - customization = JSON.parse(role.role_customization || '{}'); - } catch (e) { - console.log(e); - } - - if (widgetIdIsUUID && Array.isArray(customization[key])) { - const el = customization[key].find((e) => e === widgetId); - !el ? customization[key].unshift(widgetId) : null; - } - - if (widgetIdIsUUID && !customization[key]) { - customization[key] = [widgetId]; - } - - const newRole = await RolesDBApi.update( - role.id, - { - role_customization: JSON.stringify(customization), - name: role.name, - permissions: role.permissions, - globalAccess: role.globalAccess, - }, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - - return newRole; - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async removeRoleInfoById(infoId, roleId, key, currentUser) { - const transaction = await db.sequelize.transaction(); - - let role; - if (roleId) { - role = await RolesDBApi.findBy({ id: roleId }, { transaction }); - } else { - role = await RolesDBApi.findBy({ name: 'User' }, { transaction }); - } - if (!role) { - await transaction.rollback(); - throw new ValidationError('rolesNotFound'); - } - - let customization = {}; - try { - customization = JSON.parse(role.role_customization || '{}'); - } catch (e) { - console.log(e); - } - - customization[key] = customization[key].filter( - (item) => item !== infoId, - ); - - const response = await axios.delete(`${config.flHost}/${config.project_uuid}/project_customization_widgets/${infoId}.json`); - const { status } = await response; - try { - const result = await RolesDBApi.update( - role.id, - { - role_customization: JSON.stringify(customization), - name: role.name, - permissions: role.permissions, - globalAccess: role.globalAccess, - }, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return result; - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async getRoleInfoByKey(key, roleId, currentUser) { - const transaction = await db.sequelize.transaction(); - - const organizationId = currentUser.organizationId; - let globalAccess = currentUser.app_role?.globalAccess; - let queryString = ''; - - - - try { - const role = await findRole(roleId, currentUser); - const customization = parseCustomization(role); - - let result; - if (key === 'widgets') { - result = await processWidgets(customization[key], currentUser); - } else { - result = customization[key]; - } - - await transaction.commit(); - return result; - } catch (error) { - console.error(error); - await transaction.rollback(); - } finally { - if (transaction.finished !== 'commit') { - await transaction.rollback(); - } - } - - - } - - -}; - - diff --git a/backend/src/services/roles.ts b/backend/src/services/roles.ts new file mode 100644 index 0000000..a429268 --- /dev/null +++ b/backend/src/services/roles.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/roles'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'rolesNotFound' }); diff --git a/backend/src/services/safety_quiz_results.js b/backend/src/services/safety_quiz_results.js deleted file mode 100644 index a361adb..0000000 --- a/backend/src/services/safety_quiz_results.js +++ /dev/null @@ -1,155 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { GENERATED_ROLE_TO_PRODUCT_ROLE, PRODUCT_ROLE_VALUES } = require('../constants/roles'); -const { SAFETY_QUIZ_REPORT_ROLE_NAMES } = require('../constants/safety-quiz'); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id || currentUser?.organizationsId || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return null; -} - -function getDisplayName(currentUser) { - const firstName = currentUser?.firstName || ''; - const lastName = currentUser?.lastName || ''; - const fullName = `${firstName} ${lastName}`.trim(); - - return fullName || currentUser?.email || 'Staff Member'; -} - -function getProductRole(currentUser) { - const roleName = currentUser?.app_role?.name; - - if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { - return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; - } - - return PRODUCT_ROLE_VALUES.TEACHER; -} - -function canViewReports(currentUser) { - const roleName = currentUser?.app_role?.name; - return currentUser?.app_role?.globalAccess === true || SAFETY_QUIZ_REPORT_ROLE_NAMES.includes(roleName); -} - -function assertAuthenticatedUser(currentUser) { - if (!currentUser?.id || !getOrganizationId(currentUser)) { - throw new ForbiddenError(); - } -} - -function assertValidResult(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - const requiredStrings = ['quiz_id', 'quiz_title', 'week_of']; - const hasMissingString = requiredStrings.some((field) => { - const value = data[field]; - return typeof value !== 'string' || value.trim().length === 0; - }); - - if ( - hasMissingString - || !Number.isInteger(data.score) - || !Number.isInteger(data.total_questions) - || !Array.isArray(data.answers) - || !data.answers.every(Number.isInteger) - ) { - throw new ValidationError(); - } -} - -function toDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - quiz_id: plainRecord.quiz_id, - quiz_title: plainRecord.quiz_title, - week_of: plainRecord.week_of, - score: plainRecord.score, - total_questions: plainRecord.total_questions, - answers: plainRecord.answers, - user_name: plainRecord.user_name, - user_role: plainRecord.user_role, - completed_at: plainRecord.completed_at, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - userId: plainRecord.userId, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -module.exports = class SafetyQuizResultsService { - static async list(filter, currentUser) { - assertAuthenticatedUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - - if (!canViewReports(currentUser)) { - where.userId = currentUser.id; - } - - if (filter.week_of) { - where.week_of = filter.week_of; - } - - const result = await db.safety_quiz_results.findAndCountAll({ - where, - order: [['completed_at', 'desc']], - }); - - return { - rows: result.rows.map(toDto), - count: result.count, - }; - } - - static async create(data, currentUser) { - assertAuthenticatedUser(currentUser); - assertValidResult(data); - - const transaction = await db.sequelize.transaction(); - - try { - const created = await db.safety_quiz_results.create( - { - quiz_id: data.quiz_id.trim(), - quiz_title: data.quiz_title.trim(), - week_of: data.week_of.trim(), - score: data.score, - total_questions: data.total_questions, - answers: data.answers, - user_name: getDisplayName(currentUser), - user_role: getProductRole(currentUser), - completed_at: new Date(), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - userId: currentUser.id, - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toDto(created); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; diff --git a/backend/src/services/safety_quiz_results.ts b/backend/src/services/safety_quiz_results.ts new file mode 100644 index 0000000..b348c31 --- /dev/null +++ b/backend/src/services/safety_quiz_results.ts @@ -0,0 +1,149 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import { resolvePagination } from '@/shared/constants/pagination'; +import ValidationError from '@/shared/errors/validation'; +import { + getOrganizationIdOrGlobal, + getCampusId, + assertAuthenticatedTenantUser, + hasRoleAccess, + getDisplayName, +} from '@/services/shared/access'; +import { + GENERATED_ROLE_TO_PRODUCT_ROLE, + PRODUCT_ROLE_VALUES, +} from '@/shared/constants/roles'; +import { SAFETY_QUIZ_REPORT_ROLE_NAMES } from '@/shared/constants/safety-quiz'; +import type { SafetyQuizResults } from '@/db/models/safety_quiz_results'; +import type { CurrentUser } from '@/db/api/types'; + +interface SafetyQuizInput { + quiz_id: string; + quiz_title: string; + week_of: string; + score: number; + total_questions: number; + answers: unknown; +} + +interface SafetyQuizFilter { + week_of?: string; + limit?: number | string; + page?: number | string; +} + +const REQUIRED_STRINGS = ['quiz_id', 'quiz_title', 'week_of'] as const; + +function getProductRole(currentUser?: CurrentUser): string { + const roleName = currentUser?.app_role?.name; + + if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { + return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; + } + + return PRODUCT_ROLE_VALUES.TEACHER; +} + +function assertValidResult(data: SafetyQuizInput): void { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const hasMissingString = REQUIRED_STRINGS.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + if ( + hasMissingString || + !Number.isInteger(data.score) || + !Number.isInteger(data.total_questions) || + !Array.isArray(data.answers) || + !data.answers.every((value) => Number.isInteger(value)) + ) { + throw new ValidationError(); + } +} + +function toDto(record: SafetyQuizResults) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + quiz_id: plain.quiz_id, + quiz_title: plain.quiz_title, + week_of: plain.week_of, + score: plain.score, + total_questions: plain.total_questions, + answers: plain.answers, + user_name: plain.user_name, + user_role: plain.user_role, + completed_at: plain.completed_at, + organizationId: plain.organizationId, + campusId: plain.campusId, + userId: plain.userId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class SafetyQuizResultsService { + static async list(filter: SafetyQuizFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const result = await db.safety_quiz_results.findAndCountAll({ + where: { + ...orgFilter, + ...(hasRoleAccess(currentUser, SAFETY_QUIZ_REPORT_ROLE_NAMES) + ? {} + : { userId: currentUser?.id ?? null }), + ...(filter.week_of ? { week_of: filter.week_of } : {}), + }, + order: [['completed_at', 'desc']], + limit, + offset, + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data: SafetyQuizInput, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + assertValidResult(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + + return withTransaction(async (transaction) => { + const created = await db.safety_quiz_results.create( + { + quiz_id: data.quiz_id.trim(), + quiz_title: data.quiz_title.trim(), + week_of: data.week_of.trim(), + score: data.score, + total_questions: data.total_questions, + answers: data.answers, + user_name: getDisplayName(currentUser), + user_role: getProductRole(currentUser), + completed_at: new Date(), + organizationId, + campusId: getCampusId(currentUser), + userId: currentUser?.id ?? null, + createdById: currentUser?.id ?? null, + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + return toDto(created); + }); + } +} + +export default SafetyQuizResultsService; diff --git a/backend/src/services/search.js b/backend/src/services/search.js deleted file mode 100644 index 8c69bee..0000000 --- a/backend/src/services/search.js +++ /dev/null @@ -1,589 +0,0 @@ -const db = require('../db/models'); -const ValidationError = require('./notifications/errors/validation'); - -const Sequelize = db.Sequelize; -const Op = Sequelize.Op; - -/** - * @param {string} permission - * @param {object} currentUser - */ -async function checkPermissions(permission, currentUser) { - - if (!currentUser) { - throw new ValidationError('auth.unauthorized'); - } - - const userPermission = currentUser.custom_permissions.find( - (cp) => cp.name === permission, - ); - - if (userPermission) { - return true; - } - - try { - if (!currentUser.app_role) { - throw new ValidationError('auth.forbidden'); - } - - const permissions = await currentUser.app_role.getPermissions(); - - return !!permissions.find((p) => p.name === permission); - } catch (e) { - throw e; - } -} - -module.exports = class SearchService { - static async search(searchQuery, currentUser , organizationId, globalAccess) { - try { - if (!searchQuery) { - throw new ValidationError('iam.errors.searchQueryRequired'); - } - const tableColumns = { - - - - - - "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", - - ], - - - }; - const columnsInt = { - - - - - - - - - - - - - - - - - - - - - - - "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", - - ], - - - - - - - - - - - - - - - }; - - let allFoundRecords = []; - - for (const tableName in tableColumns) { - if (tableColumns.hasOwnProperty(tableName)) { - const attributesToSearch = tableColumns[tableName]; - const attributesIntToSearch = columnsInt[tableName] || []; - const whereCondition = { - [Op.or]: [ - ...attributesToSearch.map(attribute => ({ - [attribute]: { - [Op.iLike] : `%${searchQuery}%`, - }, - })), - ...attributesIntToSearch.map(attribute => ( - Sequelize.where( - Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), - { [Op.iLike]: `%${searchQuery}%` } - ) - )), - ], - }; - - - if (!globalAccess && tableName !== 'organizations' && organizationId) { - whereCondition.organizationId = organizationId; - } - - - const hasPermission = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser); - if (!hasPermission) { - continue; - } - - const foundRecords = await db[tableName].findAll({ - where: whereCondition, - attributes: [...tableColumns[tableName], 'id', ...attributesIntToSearch], - }); - - const modifiedRecords = foundRecords.map((record) => { - const matchAttribute = []; - - for (const attribute of attributesToSearch) { - if (record[attribute]?.toLowerCase()?.includes(searchQuery.toLowerCase())) { - matchAttribute.push(attribute); - } - } - - for (const attribute of attributesIntToSearch) { - const castedValue = String(record[attribute]); - if (castedValue && castedValue.toLowerCase().includes(searchQuery.toLowerCase())) { - matchAttribute.push(attribute); - } - } - - return { - ...record.get(), - matchAttribute, - tableName, - }; - }); - - allFoundRecords = allFoundRecords.concat(modifiedRecords); - } - } - - return allFoundRecords; - } catch (error) { - throw error; - } - } -} \ No newline at end of file diff --git a/backend/src/services/search.ts b/backend/src/services/search.ts new file mode 100644 index 0000000..dd3c8d2 --- /dev/null +++ b/backend/src/services/search.ts @@ -0,0 +1,167 @@ +import { + Op, + cast, + col, + where, + type WhereAttributeHash, + type WhereOptions, +} from 'sequelize'; +import db from '@/db/models'; +import ValidationError from '@/shared/errors/validation'; +import type { CurrentUser } from '@/db/api/types'; + +/** Text columns searched per table. */ +const TABLE_COLUMNS: Record = { + 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 searched per table (cast to text before matching). */ +const COLUMNS_INT: Record = { + 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'], +}; + +async function checkPermissions( + permission: string, + currentUser?: CurrentUser, +): Promise { + if (!currentUser) { + throw new ValidationError('auth.unauthorized'); + } + + const customPermissions = currentUser.custom_permissions ?? []; + if (customPermissions.some((cp) => cp.name === permission)) { + return true; + } + + if (!currentUser.app_role) { + throw new ValidationError('auth.forbidden'); + } + + const getPermissions = currentUser.app_role.getPermissions; + const permissions = getPermissions + ? await getPermissions.call(currentUser.app_role) + : []; + + return permissions.some((p) => p.name === permission); +} + +class SearchService { + static async search( + searchQuery: string | undefined, + currentUser: CurrentUser | undefined, + organizationId: string | null | undefined, + globalAccess: boolean, + ): Promise[]> { + if (!searchQuery) { + throw new ValidationError('iam.errors.searchQueryRequired'); + } + + const query = searchQuery.toLowerCase(); + let allFoundRecords: Record[] = []; + + for (const tableName of Object.keys(TABLE_COLUMNS)) { + const attributesToSearch = TABLE_COLUMNS[tableName]; + const attributesIntToSearch = COLUMNS_INT[tableName] ?? []; + + const stringConditions: WhereOptions[] = attributesToSearch.map( + (attribute) => ({ [attribute]: { [Op.iLike]: `%${searchQuery}%` } }), + ); + const intConditions: WhereOptions[] = attributesIntToSearch.map( + (attribute) => + where(cast(col(`${tableName}.${attribute}`), 'varchar'), { + [Op.iLike]: `%${searchQuery}%`, + }), + ); + + const whereCondition: WhereAttributeHash = { + [Op.or]: [...stringConditions, ...intConditions], + }; + + if (!globalAccess && tableName !== 'organizations' && organizationId) { + whereCondition.organizationId = organizationId; + } + + const hasPermission = await checkPermissions( + `READ_${tableName.toUpperCase()}`, + currentUser, + ); + if (!hasPermission) { + continue; + } + + const model = db.sequelize.models[tableName]; + const foundRecords = await model.findAll({ + where: whereCondition, + attributes: [...attributesToSearch, 'id', ...attributesIntToSearch], + }); + + const modifiedRecords = foundRecords.map((record) => { + const matchAttribute: string[] = []; + + for (const attribute of attributesToSearch) { + const value: unknown = record.get(attribute); + if (typeof value === 'string' && value.toLowerCase().includes(query)) { + matchAttribute.push(attribute); + } + } + + for (const attribute of attributesIntToSearch) { + const castedValue = String(record.get(attribute)); + if (castedValue.toLowerCase().includes(query)) { + matchAttribute.push(attribute); + } + } + + const plain: Record = record.get(); + return { ...plain, matchAttribute, tableName }; + }); + + allFoundRecords = allFoundRecords.concat(modifiedRecords); + } + + return allFoundRecords; + } +} + +export default SearchService; diff --git a/backend/src/services/shared/access.ts b/backend/src/services/shared/access.ts new file mode 100644 index 0000000..1af35b3 --- /dev/null +++ b/backend/src/services/shared/access.ts @@ -0,0 +1,110 @@ +import ForbiddenError from '@/shared/errors/forbidden'; +import type { CurrentUser } from '@/db/api/types'; + +/** + * Shared tenant/role access helpers used by the feature services. Centralizes + * the (previously copy-pasted) organization/campus/role checks so every module + * resolves scope and access the same way. + */ + +export function getOrganizationId(currentUser?: CurrentUser): string | null { + return currentUser?.organizations?.id || currentUser?.organizationId || null; +} + +export function getCampusId(currentUser?: CurrentUser): string | null { + const staff = currentUser?.staff_user; + if (Array.isArray(staff) && staff[0]?.campusId) { + return staff[0].campusId; + } + return currentUser?.campusId || null; +} + +export function getRoleName( + currentUser?: CurrentUser, +): string | null | undefined { + return currentUser?.app_role?.name; +} + +/** Human-friendly name for the current user (full name, else email, else generic). */ +export function getDisplayName(currentUser?: CurrentUser): string { + const firstName = currentUser?.firstName || ''; + const lastName = currentUser?.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim(); + + return fullName || currentUser?.email || 'Staff Member'; +} + +/** organizationId is a NOT NULL column; callers guard authentication first. */ +export function requireOrganizationId(currentUser?: CurrentUser): string { + const organizationId = getOrganizationId(currentUser); + if (!organizationId) { + throw new ForbiddenError(); + } + return organizationId; +} + +/** True if the user has global access (e.g. SuperAdmin, superintendent). */ +export function hasGlobalAccess(currentUser?: CurrentUser): boolean { + return currentUser?.app_role?.globalAccess === true; +} + +/** + * Returns organizationId for filtering, or null if user has global access. + * Throws ForbiddenError if user has neither global access nor organizationId. + */ +export function getOrganizationIdOrGlobal( + currentUser?: CurrentUser, +): string | null { + if (hasGlobalAccess(currentUser)) { + return null; + } + const organizationId = getOrganizationId(currentUser); + if (!organizationId) { + throw new ForbiddenError(); + } + return organizationId; +} + +export function requireUserId(currentUser?: CurrentUser): string { + if (!currentUser?.id) { + throw new ForbiddenError(); + } + return currentUser.id; +} + +export function assertAuthenticatedTenantUser(currentUser?: CurrentUser): void { + if (!currentUser?.id) { + throw new ForbiddenError(); + } + if (hasGlobalAccess(currentUser) || getOrganizationId(currentUser)) { + return; + } + throw new ForbiddenError(); +} + +/** True if the user has global access or holds one of the given role names. */ +export function hasRoleAccess( + currentUser: CurrentUser | undefined, + roleNames: readonly string[], +): boolean { + const roleName = getRoleName(currentUser); + return ( + currentUser?.app_role?.globalAccess === true || + roleNames.some((name) => name === roleName) + ); +} + +/** + * Restricts users who are not tenant-wide to their own campus. Tenant-wide roles + * (or global access) see everything. + */ +export function campusScope( + currentUser: CurrentUser | undefined, + tenantWideRoleNames: readonly string[], +): { campusId?: string } { + if (hasRoleAccess(currentUser, tenantWideRoleNames)) { + return {}; + } + const campusId = getCampusId(currentUser); + return campusId ? { campusId } : {}; +} diff --git a/backend/src/services/shared/crud-service.ts b/backend/src/services/shared/crud-service.ts new file mode 100644 index 0000000..7ca2980 --- /dev/null +++ b/backend/src/services/shared/crud-service.ts @@ -0,0 +1,140 @@ +import { withTransaction } from '@/db/with-transaction'; +import ValidationError from '@/shared/errors/validation'; +import { parseCsvRows } from '@/services/shared/csv-import'; +import type { CurrentUser, DbApiOptions } from '@/db/api/types'; + +/** + * Structural shape of a generic-CRUD `*DBApi`, generic over the entity's own + * input/output types. TypeScript infers all of these from the repository passed + * to {@link createCrudService}, so the produced service stays fully typed. + */ +export interface CrudDbApi< + CreateData, + UpdateData, + ListFilter, + BulkRow, + Entity, +> { + create(data: CreateData, options?: DbApiOptions): Promise; + bulkImport(rows: BulkRow[], options?: DbApiOptions): Promise; + update( + id: string, + data: UpdateData, + options?: DbApiOptions, + ): Promise; + deleteByIds(ids: string[], options?: DbApiOptions): Promise; + remove(id: string, options?: DbApiOptions): Promise; + findBy(where: { id: string }, options?: DbApiOptions): Promise; + findAll( + filter: ListFilter, + globalAccess: boolean, + options?: DbApiOptions, + ): Promise<{ rows: Entity[]; count: number }>; + findAllAutocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ): Promise; +} + +/** + * Builds the standard generic-CRUD service (BLL) for an entity from its + * repository, replacing the per-entity copy-paste. The controller/router + * factories wrap it for the API layer. Special entities + * (users/documents/roles/permissions/campuses) keep hand-written services. + */ +export function createCrudService< + CreateData, + UpdateData, + ListFilter, + BulkRow, + Entity, +>( + dbApi: CrudDbApi, + { notFoundCode }: { notFoundCode: string }, +) { + return { + async create(data: CreateData, currentUser?: CurrentUser): Promise { + await withTransaction((transaction) => + dbApi.create(data, { currentUser, transaction }), + ); + }, + + async bulkImport( + fileBuffer: Buffer, + currentUser?: CurrentUser, + ): Promise { + const rows = await parseCsvRows(fileBuffer); + await withTransaction((transaction) => + dbApi.bulkImport(rows, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser, + }), + ); + }, + + async update( + data: UpdateData, + id: string, + currentUser?: CurrentUser, + ): Promise { + return withTransaction(async (transaction) => { + const updated = await dbApi.update(id, data, { + currentUser, + transaction, + }); + if (!updated) { + throw new ValidationError(notFoundCode); + } + return updated; + }); + }, + + async remove(id: string, currentUser?: CurrentUser): Promise { + await withTransaction((transaction) => + dbApi.remove(id, { currentUser, transaction }), + ); + }, + + async deleteByIds(ids: string[], currentUser?: CurrentUser): Promise { + await withTransaction((transaction) => + dbApi.deleteByIds(ids, { currentUser, transaction }), + ); + }, + + list(filter: ListFilter, globalAccess: boolean, currentUser?: CurrentUser) { + return dbApi.findAll(filter, globalAccess, { currentUser }); + }, + + count(filter: ListFilter, globalAccess: boolean, currentUser?: CurrentUser) { + return dbApi.findAll(filter, globalAccess, { + countOnly: true, + currentUser, + }); + }, + + autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ) { + return dbApi.findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ); + }, + + findById(id: string) { + return dbApi.findBy({ id }); + }, + }; +} diff --git a/backend/src/services/shared/csv-import.ts b/backend/src/services/shared/csv-import.ts new file mode 100644 index 0000000..8f0152f --- /dev/null +++ b/backend/src/services/shared/csv-import.ts @@ -0,0 +1,17 @@ +import { PassThrough } from 'stream'; +import csv from 'csv-parser'; + +/** Parses an uploaded CSV buffer into typed bulk-import rows. */ +export function parseCsvRows(fileBuffer: Buffer): Promise { + const bufferStream = new PassThrough(); + const results: Row[] = []; + bufferStream.end(Buffer.from(fileBuffer)); + + return new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (row: Row) => results.push(row)) + .on('end', () => resolve(results)) + .on('error', reject); + }); +} diff --git a/backend/src/services/shared/validate.ts b/backend/src/services/shared/validate.ts new file mode 100644 index 0000000..4c18b64 --- /dev/null +++ b/backend/src/services/shared/validate.ts @@ -0,0 +1,45 @@ +import ValidationError from '@/shared/errors/validation'; +import { ISO_DATE_PATTERN } from '@/shared/constants/validation'; + +/** Parses a positive-integer list limit, defaulting and capping it. */ +export function clampLimit( + value: unknown, + defaultLimit: number, + maxLimit: number, +): number { + if (value === undefined) { + return defaultLimit; + } + + const limit = Number(value); + + if (!Number.isInteger(limit) || limit <= 0) { + throw new ValidationError(); + } + + return Math.min(limit, maxLimit); +} + +/** Trims a string, returning null for non-strings or blanks. */ +export function nullableString(value: unknown): string | null { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + return value.trim(); +} + +/** Requires an ISO calendar date (`YYYY-MM-DD`); throws otherwise. */ +export function requiredIsoDate(value: unknown): string { + if (typeof value !== 'string' || !ISO_DATE_PATTERN.test(value)) { + throw new ValidationError(); + } + return value; +} + +/** Like {@link requiredIsoDate}, but `undefined` yields `null` (optional). */ +export function optionalIsoDate(value: unknown): string | null { + if (value === undefined) { + return null; + } + return requiredIsoDate(value); +} diff --git a/backend/src/services/staff.js b/backend/src/services/staff.js deleted file mode 100644 index 5e90d31..0000000 --- a/backend/src/services/staff.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const StaffDBApi = require('../db/api/staff'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class StaffService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await StaffDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await StaffDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let staff = await StaffDBApi.findBy( - {id}, - {transaction}, - ); - - if (!staff) { - throw new ValidationError( - 'staffNotFound', - ); - } - - const updatedStaff = await StaffDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedStaff; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await StaffDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await StaffDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/staff.ts b/backend/src/services/staff.ts new file mode 100644 index 0000000..cfe2de3 --- /dev/null +++ b/backend/src/services/staff.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/staff'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'staffNotFound' }); diff --git a/backend/src/services/staff_attendance.js b/backend/src/services/staff_attendance.js deleted file mode 100644 index 96dc440..0000000 --- a/backend/src/services/staff_attendance.js +++ /dev/null @@ -1,192 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { - STAFF_ATTENDANCE_DEFAULT_LIMIT, - STAFF_ATTENDANCE_MAX_LIMIT, - STAFF_ATTENDANCE_REPORT_ROLE_NAMES, - STAFF_ATTENDANCE_STATUSES, - STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, -} = require('../constants/staff-attendance'); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id - || currentUser?.organization?.id - || currentUser?.organizationsId - || currentUser?.organizationId - || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return currentUser?.campusId || null; -} - -function getRoleName(currentUser) { - return currentUser?.app_role?.name; -} - -function assertAuthenticatedTenantUser(currentUser) { - if (currentUser?.id && getOrganizationId(currentUser)) { - return; - } - - throw new ForbiddenError(); -} - -function canViewReports(currentUser) { - return currentUser?.app_role?.globalAccess === true - || STAFF_ATTENDANCE_REPORT_ROLE_NAMES.includes(getRoleName(currentUser)); -} - -function hasTenantWideAccess(currentUser) { - return currentUser?.app_role?.globalAccess === true - || STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES.includes(getRoleName(currentUser)); -} - -function parseLimit(value) { - if (value === undefined) { - return STAFF_ATTENDANCE_DEFAULT_LIMIT; - } - - const limit = Number(value); - - if (!Number.isInteger(limit) || limit <= 0) { - throw new ValidationError(); - } - - return Math.min(limit, STAFF_ATTENDANCE_MAX_LIMIT); -} - -function requiredDate(value) { - if (value === undefined) { - return null; - } - - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) { - throw new ValidationError(); - } - - return value; -} - -function applyVisibilityScope(where, currentUser) { - if (!canViewReports(currentUser)) { - where.userId = currentUser.id; - return; - } - - if (hasTenantWideAccess(currentUser)) { - return; - } - - const campusId = getCampusId(currentUser); - - if (campusId) { - where.campusId = campusId; - } -} - -function applyDateFilter(where, filter) { - const startDate = requiredDate(filter.startDate); - const endDate = requiredDate(filter.endDate); - - if (startDate) { - where.attendance_date = { - ...(where.attendance_date || {}), - [db.Sequelize.Op.gte]: startDate, - }; - } - - if (endDate) { - where.attendance_date = { - ...(where.attendance_date || {}), - [db.Sequelize.Op.lte]: endDate, - }; - } -} - -function applyStaffScope(where, currentUser) { - where.organizationId = getOrganizationId(currentUser); - - if (!hasTenantWideAccess(currentUser)) { - const campusId = getCampusId(currentUser); - - if (campusId) { - where.campusId = campusId; - } - } -} - -function toRecordDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - date: plainRecord.attendance_date, - status: plainRecord.status, - note: plainRecord.note, - user_name: plainRecord.user_name, - user_role: plainRecord.user_role, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - userId: plainRecord.userId, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -function countStatus(records, status) { - return records.filter((record) => record.status === status).length; -} - -module.exports = class StaffAttendanceService { - static async listRecords(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - applyVisibilityScope(where, currentUser); - applyDateFilter(where, filter); - - const result = await db.staff_attendance_records.findAndCountAll({ - where, - limit: parseLimit(filter.limit), - order: [['attendance_date', 'desc'], ['user_name', 'asc']], - }); - - return { - rows: result.rows.map(toRecordDto), - count: result.count, - }; - } - - static async summary(filter, currentUser) { - assertAuthenticatedTenantUser(currentUser); - - const recordsPayload = await this.listRecords(filter, currentUser); - const staffWhere = {}; - applyStaffScope(staffWhere, currentUser); - staffWhere.status = 'active'; - - const staffCount = await db.staff.count({ where: staffWhere }); - const records = recordsPayload.rows; - const present = countStatus(records, STAFF_ATTENDANCE_STATUSES.PRESENT); - const late = countStatus(records, STAFF_ATTENDANCE_STATUSES.LATE); - const absent = countStatus(records, STAFF_ATTENDANCE_STATUSES.ABSENT); - - return { - staffCount, - recordsCount: recordsPayload.count, - present, - late, - absent, - }; - } -}; diff --git a/backend/src/services/staff_attendance.ts b/backend/src/services/staff_attendance.ts new file mode 100644 index 0000000..60bbba7 --- /dev/null +++ b/backend/src/services/staff_attendance.ts @@ -0,0 +1,145 @@ +import { clampLimit, optionalIsoDate } from '@/services/shared/validate'; +import { Op } from 'sequelize'; +import db from '@/db/models'; +import { + STAFF_ATTENDANCE_DEFAULT_LIMIT, + STAFF_ATTENDANCE_MAX_LIMIT, + STAFF_ATTENDANCE_REPORT_ROLE_NAMES, + STAFF_ATTENDANCE_STATUSES, + STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, +} from '@/shared/constants/staff-attendance'; +import { STAFF_STATUSES } from '@/shared/constants/staff'; +import { + assertAuthenticatedTenantUser, + campusScope, + hasRoleAccess, + requireOrganizationId, + requireUserId, +} from '@/services/shared/access'; +import type { StaffAttendanceRecords } from '@/db/models/staff_attendance_records'; +import type { CurrentUser } from '@/db/api/types'; + +interface StaffAttendanceFilter { + startDate?: unknown; + endDate?: unknown; + limit?: unknown; +} + +/** Restricts records to the staff member, their campus, or the whole tenant. */ +function visibilityScope( + currentUser?: CurrentUser, +): { userId?: string; campusId?: string } { + if (!hasRoleAccess(currentUser, STAFF_ATTENDANCE_REPORT_ROLE_NAMES)) { + return { userId: requireUserId(currentUser) }; + } + + return campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES); +} + +function dateFilter(filter: StaffAttendanceFilter): { + attendance_date?: { [Op.gte]?: string; [Op.lte]?: string }; +} { + const startDate = optionalIsoDate(filter.startDate); + const endDate = optionalIsoDate(filter.endDate); + + if (!startDate && !endDate) { + return {}; + } + + return { + attendance_date: { + ...(startDate ? { [Op.gte]: startDate } : {}), + ...(endDate ? { [Op.lte]: endDate } : {}), + }, + }; +} + +function toRecordDto(record: StaffAttendanceRecords) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + date: plain.attendance_date, + status: plain.status, + note: plain.note, + user_name: plain.user_name, + user_role: plain.user_role, + organizationId: plain.organizationId, + campusId: plain.campusId, + userId: plain.userId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class StaffAttendanceService { + static async listRecords( + filter: StaffAttendanceFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + const result = await db.staff_attendance_records.findAndCountAll({ + where: { + organizationId: requireOrganizationId(currentUser), + ...visibilityScope(currentUser), + ...dateFilter(filter), + }, + limit: clampLimit(filter.limit, STAFF_ATTENDANCE_DEFAULT_LIMIT, STAFF_ATTENDANCE_MAX_LIMIT), + order: [ + ['attendance_date', 'desc'], + ['user_name', 'asc'], + ], + }); + + return { + rows: result.rows.map(toRecordDto), + count: result.count, + }; + } + + static async summary( + filter: StaffAttendanceFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + + // Aggregate in SQL (COUNT per status) instead of fetching every record and + // counting in JS — avoids the limit-truncated, incorrect totals and the + // large row transfer. + const recordsWhere = { + organizationId: requireOrganizationId(currentUser), + ...visibilityScope(currentUser), + ...dateFilter(filter), + }; + + const [present, late, absent, staffCount] = await Promise.all([ + db.staff_attendance_records.count({ + where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.PRESENT }, + }), + db.staff_attendance_records.count({ + where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.LATE }, + }), + db.staff_attendance_records.count({ + where: { ...recordsWhere, status: STAFF_ATTENDANCE_STATUSES.ABSENT }, + }), + db.staff.count({ + where: { + organizationId: requireOrganizationId(currentUser), + status: STAFF_STATUSES.ACTIVE, + ...campusScope(currentUser, STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES), + }, + }), + ]); + + return { + staffCount, + recordsCount: present + late + absent, + present, + late, + absent, + }; + } +} + +export default StaffAttendanceService; diff --git a/backend/src/services/students.js b/backend/src/services/students.js deleted file mode 100644 index f0d4ac2..0000000 --- a/backend/src/services/students.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const StudentsDBApi = require('../db/api/students'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class StudentsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await StudentsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await StudentsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let students = await StudentsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!students) { - throw new ValidationError( - 'studentsNotFound', - ); - } - - const updatedStudents = await StudentsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedStudents; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await StudentsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await StudentsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts new file mode 100644 index 0000000..31ab9d1 --- /dev/null +++ b/backend/src/services/students.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/students'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'studentsNotFound' }); diff --git a/backend/src/services/subjects.js b/backend/src/services/subjects.js deleted file mode 100644 index af47b04..0000000 --- a/backend/src/services/subjects.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const SubjectsDBApi = require('../db/api/subjects'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class SubjectsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await SubjectsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await SubjectsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let subjects = await SubjectsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!subjects) { - throw new ValidationError( - 'subjectsNotFound', - ); - } - - const updatedSubjects = await SubjectsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedSubjects; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await SubjectsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await SubjectsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/subjects.ts b/backend/src/services/subjects.ts new file mode 100644 index 0000000..ec43300 --- /dev/null +++ b/backend/src/services/subjects.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/subjects'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'subjectsNotFound' }); diff --git a/backend/src/services/timetable_periods.js b/backend/src/services/timetable_periods.js deleted file mode 100644 index 36ddc71..0000000 --- a/backend/src/services/timetable_periods.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const Timetable_periodsDBApi = require('../db/api/timetable_periods'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class Timetable_periodsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await Timetable_periodsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await Timetable_periodsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let timetable_periods = await Timetable_periodsDBApi.findBy( - {id}, - {transaction}, - ); - - if (!timetable_periods) { - throw new ValidationError( - 'timetable_periodsNotFound', - ); - } - - const updatedTimetable_periods = await Timetable_periodsDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedTimetable_periods; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Timetable_periodsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await Timetable_periodsDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/timetable_periods.ts b/backend/src/services/timetable_periods.ts new file mode 100644 index 0000000..e84f549 --- /dev/null +++ b/backend/src/services/timetable_periods.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/timetable_periods'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'timetable_periodsNotFound' }); diff --git a/backend/src/services/timetables.js b/backend/src/services/timetables.js deleted file mode 100644 index 609f2cd..0000000 --- a/backend/src/services/timetables.js +++ /dev/null @@ -1,138 +0,0 @@ -const db = require('../db/models'); -const TimetablesDBApi = require('../db/api/timetables'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - - - - -module.exports = class TimetablesService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - await TimetablesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) - - await TimetablesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let timetables = await TimetablesDBApi.findBy( - {id}, - {transaction}, - ); - - if (!timetables) { - throw new ValidationError( - 'timetablesNotFound', - ); - } - - const updatedTimetables = await TimetablesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedTimetables; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await TimetablesDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await TimetablesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - -}; - - diff --git a/backend/src/services/timetables.ts b/backend/src/services/timetables.ts new file mode 100644 index 0000000..7a24fb3 --- /dev/null +++ b/backend/src/services/timetables.ts @@ -0,0 +1,4 @@ +import DbApi from '@/db/api/timetables'; +import { createCrudService } from '@/services/shared/crud-service'; + +export default createCrudService(DbApi, { notFoundCode: 'timetablesNotFound' }); diff --git a/backend/src/services/user_progress.js b/backend/src/services/user_progress.js deleted file mode 100644 index 5d528d6..0000000 --- a/backend/src/services/user_progress.js +++ /dev/null @@ -1,160 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id || currentUser?.organizationsId || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return null; -} - -function assertAuthenticatedUser(currentUser) { - if (!currentUser?.id || !getOrganizationId(currentUser)) { - throw new ForbiddenError(); - } -} - -function assertValidProgressType(progressType) { - if (typeof progressType !== 'string' || progressType.trim().length === 0) { - throw new ValidationError(); - } -} - -function assertValidMutation(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - assertValidProgressType(data.progress_type); - - if (typeof data.item_id !== 'string' || data.item_id.trim().length === 0) { - throw new ValidationError(); - } -} - -function toDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - progress_type: plainRecord.progress_type, - item_id: plainRecord.item_id, - value: plainRecord.value, - score: plainRecord.score, - metadata: plainRecord.metadata, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - userId: plainRecord.userId, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -module.exports = class UserProgressService { - static async list(filter, currentUser) { - assertAuthenticatedUser(currentUser); - assertValidProgressType(filter.progress_type); - - const where = { - organizationId: getOrganizationId(currentUser), - userId: currentUser.id, - progress_type: filter.progress_type.trim(), - }; - - if (filter.item_id) { - where.item_id = filter.item_id; - } - - const result = await db.user_progress.findAndCountAll({ - where, - order: [['createdAt', 'desc']], - }); - - return { - rows: result.rows.map(toDto), - count: result.count, - }; - } - - static async upsert(data, currentUser) { - assertAuthenticatedUser(currentUser); - assertValidMutation(data); - - const organizationId = getOrganizationId(currentUser); - const progressType = data.progress_type.trim(); - const itemId = data.item_id.trim(); - const transaction = await db.sequelize.transaction(); - - try { - const existing = await db.user_progress.findOne({ - where: { - organizationId, - userId: currentUser.id, - progress_type: progressType, - item_id: itemId, - }, - transaction, - }); - - const payload = { - progress_type: progressType, - item_id: itemId, - value: typeof data.value === 'string' ? data.value : null, - score: Number.isInteger(data.score) ? data.score : null, - metadata: data.metadata || null, - organizationId, - campusId: getCampusId(currentUser), - userId: currentUser.id, - updatedById: currentUser.id, - }; - - if (existing) { - await existing.update(payload, { transaction }); - await transaction.commit(); - return toDto(existing); - } - - const created = await db.user_progress.create( - { - ...payload, - createdById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toDto(created); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async removeByItem(filter, currentUser) { - assertAuthenticatedUser(currentUser); - assertValidProgressType(filter.progress_type); - - if (typeof filter.item_id !== 'string' || filter.item_id.trim().length === 0) { - throw new ValidationError(); - } - - const deletedCount = await db.user_progress.destroy({ - where: { - organizationId: getOrganizationId(currentUser), - userId: currentUser.id, - progress_type: filter.progress_type.trim(), - item_id: filter.item_id.trim(), - }, - }); - - return { deletedCount }; - } -}; diff --git a/backend/src/services/user_progress.ts b/backend/src/services/user_progress.ts new file mode 100644 index 0000000..7f26f3a --- /dev/null +++ b/backend/src/services/user_progress.ts @@ -0,0 +1,177 @@ +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import ValidationError from '@/shared/errors/validation'; +import { + assertAuthenticatedTenantUser, + getCampusId, + getOrganizationIdOrGlobal, + requireUserId, +} from '@/services/shared/access'; +import { + USER_PROGRESS_TYPE_VALUES, + type UserProgressType, +} from '@/shared/constants/user-progress'; +import { resolvePagination } from '@/shared/constants/pagination'; +import type { UserProgress } from '@/db/models/user_progress'; +import type { CurrentUser } from '@/db/api/types'; + +interface UserProgressInput { + progress_type: string; + item_id: string; + value?: unknown; + score?: unknown; + metadata?: unknown; +} + +interface UserProgressFilter { + progress_type?: string; + item_id?: string; + limit?: number | string; + page?: number | string; +} + +function assertValidProgressType(progressType: unknown): UserProgressType { + const match = USER_PROGRESS_TYPE_VALUES.find((item) => item === progressType); + if (!match) { + throw new ValidationError(); + } + return match; +} + +function assertValidMutation(data: UserProgressInput): void { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + assertValidProgressType(data.progress_type); + + if (typeof data.item_id !== 'string' || data.item_id.trim().length === 0) { + throw new ValidationError(); + } +} + +function toDto(record: UserProgress) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + progress_type: plain.progress_type, + item_id: plain.item_id, + value: plain.value, + score: plain.score, + metadata: plain.metadata, + organizationId: plain.organizationId, + campusId: plain.campusId, + userId: plain.userId, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +class UserProgressService { + static async list(filter: UserProgressFilter, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + const progressType = assertValidProgressType(filter.progress_type); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const result = await db.user_progress.findAndCountAll({ + where: { + ...orgFilter, + userId: requireUserId(currentUser), + progress_type: progressType, + ...(filter.item_id ? { item_id: filter.item_id } : {}), + }, + order: [['createdAt', 'desc']], + limit, + offset, + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async upsert(data: UserProgressInput, currentUser?: CurrentUser) { + assertAuthenticatedTenantUser(currentUser); + assertValidMutation(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + const progressType = assertValidProgressType(data.progress_type); + const itemId = data.item_id.trim(); + + return withTransaction(async (transaction) => { + const existing = await db.user_progress.findOne({ + where: { + ...orgFilter, + userId: requireUserId(currentUser), + progress_type: progressType, + item_id: itemId, + }, + transaction, + }); + + const payload = { + progress_type: progressType, + item_id: itemId, + value: typeof data.value === 'string' ? data.value : null, + score: typeof data.score === 'number' ? data.score : null, + metadata: data.metadata ?? null, + organizationId, + campusId: getCampusId(currentUser), + userId: requireUserId(currentUser), + updatedById: currentUser?.id ?? null, + }; + + if (existing) { + await existing.update(payload, { transaction }); + return toDto(existing); + } + + const created = await db.user_progress.create( + { + ...payload, + createdById: requireUserId(currentUser), + }, + { transaction }, + ); + + return toDto(created); + }); + } + + static async removeByItem( + filter: UserProgressFilter, + currentUser?: CurrentUser, + ) { + assertAuthenticatedTenantUser(currentUser); + const progressType = assertValidProgressType(filter.progress_type); + + if ( + typeof filter.item_id !== 'string' || + filter.item_id.trim().length === 0 + ) { + throw new ValidationError(); + } + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const deletedCount = await db.user_progress.destroy({ + where: { + ...orgFilter, + userId: requireUserId(currentUser), + progress_type: progressType, + item_id: filter.item_id.trim(), + }, + }); + + return { deletedCount }; + } +} + +export default UserProgressService; diff --git a/backend/src/services/users.js b/backend/src/services/users.js deleted file mode 100644 index 41f8220..0000000 --- a/backend/src/services/users.js +++ /dev/null @@ -1,179 +0,0 @@ -const db = require('../db/models'); -const UsersDBApi = require('../db/api/users'); -const processFile = require("../middlewares/upload"); -const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); -const stream = require('stream'); - - -const InvitationEmail = require('./email/list/invitation'); -const EmailSender = require('./email'); -const AuthService = require('./auth'); - -module.exports = class UsersService { - static async create(data, currentUser, sendInvitationEmails = true, host) { - let transaction = await db.sequelize.transaction(); - - const globalAccess = currentUser.app_role.globalAccess; - - let email = data.email; - let emailsToInvite = []; - try { - if (email) { - let user = await UsersDBApi.findBy({email}, {transaction}); - if (user) { - throw new ValidationError( - 'iam.errors.userAlreadyExists', - ); - } else { - await UsersDBApi.create( - {data}, - - globalAccess, - - { - currentUser, - transaction, - }, - ); - emailsToInvite.push(email); - } - } else { - throw new ValidationError('iam.errors.emailRequired') - } - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - if (emailsToInvite && emailsToInvite.length) { - if (!sendInvitationEmails) return; - - AuthService.sendPasswordResetEmail(email, 'invitation', host); - } - } - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - let emailsToInvite = []; - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', () => { - console.log('results csv', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }); - - const hasAllEmails = results.every((result) => result.email); - - if (!hasAllEmails) { - throw new ValidationError('importer.errors.userEmailMissing'); - } - - await UsersDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser - }); - - emailsToInvite = results.map((result) => result.email); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - - if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) { - - emailsToInvite.forEach((email) => { - AuthService.sendPasswordResetEmail(email, 'invitation', host); - }); - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - - const globalAccess = currentUser.app_role.globalAccess; - - try { - let users = await UsersDBApi.findBy( - {id}, - {transaction}, - ); - - if (!users) { - throw new ValidationError( - 'iam.errors.userNotFound', - ); - } - - const updatedUser = await UsersDBApi.update( - id, - data, - - globalAccess, - - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedUser; - - } catch (error) { - await transaction.rollback(); - throw error; - } - }; - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - if (currentUser.id === id) { - throw new ValidationError( - 'iam.errors.deletingHimself', - ); - } - - if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin ) { - throw new ValidationError( - 'errors.forbidden.message', - ); - } - - await UsersDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; - - diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts new file mode 100644 index 0000000..343e966 --- /dev/null +++ b/backend/src/services/users.ts @@ -0,0 +1,211 @@ +import { PassThrough } from 'stream'; +import csv from 'csv-parser'; +import db from '@/db/models'; +import UsersDBApi from '@/db/api/users'; +import config from '@/shared/config'; +import ValidationError from '@/shared/errors/validation'; +import AuthService from '@/services/auth'; +import type { CurrentUser } from '@/db/api/types'; + +type CreateData = Parameters[0]['data']; +type UpdateData = Parameters[1]; +type ListFilter = Parameters[0]; +type BulkRow = Parameters[0][number]; + +/** Parses an uploaded CSV buffer into bulk-import rows. */ +function parseCsvRows(fileBuffer: Buffer): Promise { + const bufferStream = new PassThrough(); + const results: BulkRow[] = []; + bufferStream.end(Buffer.from(fileBuffer)); + + return new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (row: BulkRow) => results.push(row)) + .on('end', () => resolve(results)) + .on('error', reject); + }); +} + +class UsersService { + static async create( + data: CreateData, + currentUser?: CurrentUser, + sendInvitationEmails = true, + host?: string, + ) { + const transaction = await db.sequelize.transaction(); + const globalAccess = currentUser?.app_role?.globalAccess ?? false; + + const email = data.email; + const emailsToInvite: string[] = []; + + try { + if (email) { + const user = await UsersDBApi.findBy({ email }, { transaction }); + if (user) { + throw new ValidationError('iam.errors.userAlreadyExists'); + } + + await UsersDBApi.create({ data }, globalAccess, { + currentUser, + transaction, + }); + emailsToInvite.push(email); + } else { + throw new ValidationError('iam.errors.emailRequired'); + } + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + if (emailsToInvite.length) { + if (!sendInvitationEmails) return; + + AuthService.sendPasswordResetEmail(emailsToInvite[0], 'invitation', host); + } + } + + static async bulkImport( + fileBuffer: Buffer, + currentUser?: CurrentUser, + sendInvitationEmails = true, + host?: string, + ) { + const rows = await parseCsvRows(fileBuffer); + const emailsToInvite: string[] = []; + const transaction = await db.sequelize.transaction(); + + try { + const hasAllEmails = rows.every((row) => row.email); + + if (!hasAllEmails) { + throw new ValidationError('importer.errors.userEmailMissing'); + } + + await UsersDBApi.bulkImport(rows, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser, + }); + + for (const row of rows) { + if (row.email) emailsToInvite.push(row.email); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + + if (emailsToInvite.length && !sendInvitationEmails) { + emailsToInvite.forEach((email) => { + AuthService.sendPasswordResetEmail(email, 'invitation', host); + }); + } + } + + static async update(data: UpdateData, id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + const globalAccess = currentUser?.app_role?.globalAccess ?? false; + + try { + const users = await UsersDBApi.findBy({ id }, { transaction }); + + if (!users) { + throw new ValidationError('iam.errors.userNotFound'); + } + + const updatedUser = await UsersDBApi.update(id, data, globalAccess, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedUser; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id: string, currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + + try { + if (currentUser?.id === id) { + throw new ValidationError('iam.errors.deletingHimself'); + } + + if ( + currentUser?.app_role?.name !== config.roles.admin && + currentUser?.app_role?.name !== config.roles.super_admin + ) { + throw new ValidationError('errors.forbidden.message'); + } + + await UsersDBApi.remove(id, { currentUser, transaction }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids: string[], currentUser?: CurrentUser) { + const transaction = await db.sequelize.transaction(); + try { + await UsersDBApi.deleteByIds(ids, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static list( + filter: ListFilter, + globalAccess: boolean, + currentUser?: CurrentUser, + ) { + return UsersDBApi.findAll(filter, globalAccess, { currentUser }); + } + + static count( + filter: ListFilter, + globalAccess: boolean, + currentUser?: CurrentUser, + ) { + return UsersDBApi.findAll(filter, globalAccess, { + countOnly: true, + currentUser, + }); + } + + static autocomplete( + query: string | undefined, + limit: number | undefined, + offset: number | undefined, + globalAccess: boolean, + organizationId?: string, + ) { + return UsersDBApi.findAllAutocomplete( + query, + limit, + offset, + globalAccess, + organizationId, + ); + } + + static findById(id: string) { + return UsersDBApi.findBy({ id }); + } +} + +export default UsersService; diff --git a/backend/src/services/walkthrough_checkins.js b/backend/src/services/walkthrough_checkins.js deleted file mode 100644 index 9557fc3..0000000 --- a/backend/src/services/walkthrough_checkins.js +++ /dev/null @@ -1,210 +0,0 @@ -const db = require('../db/models'); -const ForbiddenError = require('./notifications/errors/forbidden'); -const ValidationError = require('./notifications/errors/validation'); -const { - WALKTHROUGH_MANAGER_ROLE_NAMES, - WALKTHROUGH_TENANT_WIDE_ROLE_NAMES, -} = require('../constants/walkthrough'); - -const REQUIRED_STRING_FIELDS = [ - 'teacher_name', - 'classroom', - 'director_name', - 'check_in_date', - 'check_in_time', -]; - -const RATING_FIELDS = [ - 'attitude_rating', - 'classroom_management_rating', - 'cleanliness_rating', - 'vibes_rating', - 'team_dynamics_rating', - 'emergency_exit_rating', - 'lesson_plan_rating', -]; - -function getOrganizationId(currentUser) { - return currentUser?.organizations?.id || currentUser?.organizationsId || null; -} - -function getCampusId(currentUser) { - if (Array.isArray(currentUser?.staff_user) && currentUser.staff_user[0]?.campusId) { - return currentUser.staff_user[0].campusId; - } - - return null; -} - -function assertCanManage(currentUser) { - const roleName = currentUser?.app_role?.name; - const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; - - if (currentUser?.id && getOrganizationId(currentUser) && (hasGlobalAccess || WALKTHROUGH_MANAGER_ROLE_NAMES.includes(roleName))) { - return; - } - - throw new ForbiddenError(); -} - -function assertValidCheckin(data) { - if (!data || typeof data !== 'object' || Array.isArray(data)) { - throw new ValidationError(); - } - - const missingString = REQUIRED_STRING_FIELDS.some((field) => { - const value = data[field]; - return typeof value !== 'string' || value.trim().length === 0; - }); - - const missingRating = RATING_FIELDS.some((field) => !Number.isInteger(data[field])); - - if (missingString || missingRating) { - throw new ValidationError(); - } -} - -function nullableString(value) { - if (typeof value !== 'string' || value.trim().length === 0) { - return null; - } - - return value.trim(); -} - -function toDto(record) { - const plainRecord = typeof record.get === 'function' - ? record.get({ plain: true }) - : record; - - return { - id: plainRecord.id, - teacher_name: plainRecord.teacher_name, - classroom: plainRecord.classroom, - director_name: plainRecord.director_name, - check_in_date: plainRecord.check_in_date, - check_in_time: plainRecord.check_in_time, - attitude_rating: plainRecord.attitude_rating, - attitude_comment: plainRecord.attitude_comment, - classroom_management_rating: plainRecord.classroom_management_rating, - classroom_management_comment: plainRecord.classroom_management_comment, - cleanliness_rating: plainRecord.cleanliness_rating, - cleanliness_comment: plainRecord.cleanliness_comment, - vibes_rating: plainRecord.vibes_rating, - vibes_comment: plainRecord.vibes_comment, - team_dynamics_rating: plainRecord.team_dynamics_rating, - team_dynamics_comment: plainRecord.team_dynamics_comment, - emergency_exit_rating: plainRecord.emergency_exit_rating, - emergency_exit_comment: plainRecord.emergency_exit_comment, - lesson_plan_rating: plainRecord.lesson_plan_rating, - lesson_plan_comment: plainRecord.lesson_plan_comment, - overall_notes: plainRecord.overall_notes, - organizationId: plainRecord.organizationId, - campusId: plainRecord.campusId, - createdById: plainRecord.createdById, - updatedById: plainRecord.updatedById, - createdAt: plainRecord.createdAt, - updatedAt: plainRecord.updatedAt, - }; -} - -function applyCampusScope(where, currentUser) { - const roleName = currentUser?.app_role?.name; - const hasGlobalAccess = currentUser?.app_role?.globalAccess === true; - - if (hasGlobalAccess || WALKTHROUGH_TENANT_WIDE_ROLE_NAMES.includes(roleName)) { - return; - } - - const campusId = getCampusId(currentUser); - - if (campusId) { - where.campusId = campusId; - } -} - -module.exports = class WalkthroughCheckinsService { - static async list(filter, currentUser) { - assertCanManage(currentUser); - - const where = { - organizationId: getOrganizationId(currentUser), - }; - applyCampusScope(where, currentUser); - - if (filter.teacher_name) { - where.teacher_name = filter.teacher_name; - } - - const result = await db.walkthrough_checkins.findAndCountAll({ - where, - order: [['check_in_date', 'desc'], ['createdAt', 'desc']], - }); - - return { - rows: result.rows.map(toDto), - count: result.count, - }; - } - - static async create(data, currentUser) { - assertCanManage(currentUser); - assertValidCheckin(data); - - const transaction = await db.sequelize.transaction(); - - try { - const created = await db.walkthrough_checkins.create( - { - teacher_name: data.teacher_name.trim(), - classroom: data.classroom.trim(), - director_name: data.director_name.trim(), - check_in_date: data.check_in_date.trim(), - check_in_time: data.check_in_time.trim(), - attitude_rating: data.attitude_rating, - attitude_comment: nullableString(data.attitude_comment), - classroom_management_rating: data.classroom_management_rating, - classroom_management_comment: nullableString(data.classroom_management_comment), - cleanliness_rating: data.cleanliness_rating, - cleanliness_comment: nullableString(data.cleanliness_comment), - vibes_rating: data.vibes_rating, - vibes_comment: nullableString(data.vibes_comment), - team_dynamics_rating: data.team_dynamics_rating, - team_dynamics_comment: nullableString(data.team_dynamics_comment), - emergency_exit_rating: data.emergency_exit_rating, - emergency_exit_comment: nullableString(data.emergency_exit_comment), - lesson_plan_rating: data.lesson_plan_rating, - lesson_plan_comment: nullableString(data.lesson_plan_comment), - overall_notes: nullableString(data.overall_notes), - organizationId: getOrganizationId(currentUser), - campusId: getCampusId(currentUser), - createdById: currentUser.id, - updatedById: currentUser.id, - }, - { transaction }, - ); - - await transaction.commit(); - return toDto(created); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - assertCanManage(currentUser); - - const where = { - id, - organizationId: getOrganizationId(currentUser), - }; - applyCampusScope(where, currentUser); - - const deletedCount = await db.walkthrough_checkins.destroy({ - where, - }); - - return { deletedCount }; - } -}; diff --git a/backend/src/services/walkthrough_checkins.ts b/backend/src/services/walkthrough_checkins.ts new file mode 100644 index 0000000..dd17693 --- /dev/null +++ b/backend/src/services/walkthrough_checkins.ts @@ -0,0 +1,223 @@ +import { nullableString } from '@/services/shared/validate'; +import db from '@/db/models'; +import { withTransaction } from '@/db/with-transaction'; +import { resolvePagination } from '@/shared/constants/pagination'; +import ForbiddenError from '@/shared/errors/forbidden'; +import ValidationError from '@/shared/errors/validation'; +import { + WALKTHROUGH_MANAGER_ROLE_NAMES, + WALKTHROUGH_TENANT_WIDE_ROLE_NAMES, +} from '@/shared/constants/walkthrough'; +import { + getOrganizationIdOrGlobal, + hasGlobalAccess, + hasRoleAccess, + requireUserId, +} from '@/services/shared/access'; +import type { WalkthroughCheckins } from '@/db/models/walkthrough_checkins'; +import type { CurrentUser } from '@/db/api/types'; +import type { + WalkthroughInput, + WalkthroughFilter, +} from '@/services/walkthrough_checkins.types'; + +const REQUIRED_STRING_FIELDS = [ + 'teacher_name', + 'classroom', + 'director_name', + 'check_in_date', + 'check_in_time', +] as const; + +const RATING_FIELDS = [ + 'attitude_rating', + 'classroom_management_rating', + 'cleanliness_rating', + 'vibes_rating', + 'team_dynamics_rating', + 'emergency_exit_rating', + 'lesson_plan_rating', +] as const; + +/** Walkthrough scopes by the staff member's campus only (never the user's own + * campusId), so this keeps its module-local resolver. */ +function getCampusId(currentUser?: CurrentUser): string | null { + const staff = currentUser?.staff_user; + if (Array.isArray(staff) && staff[0]?.campusId) { + return staff[0].campusId; + } + + return null; +} + +function assertCanManage(currentUser?: CurrentUser): void { + if (!currentUser?.id) { + throw new ForbiddenError(); + } + + if (hasGlobalAccess(currentUser)) { + return; + } + + const organizationId = getOrganizationIdOrGlobal(currentUser); + if (organizationId && hasRoleAccess(currentUser, WALKTHROUGH_MANAGER_ROLE_NAMES)) { + return; + } + + throw new ForbiddenError(); +} + +function assertValidCheckin(data: WalkthroughInput): void { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new ValidationError(); + } + + const missingString = REQUIRED_STRING_FIELDS.some((field) => { + const value = data[field]; + return typeof value !== 'string' || value.trim().length === 0; + }); + + const missingRating = RATING_FIELDS.some( + (field) => typeof data[field] !== 'number' || !Number.isFinite(data[field]), + ); + + if (missingString || missingRating) { + throw new ValidationError(); + } +} + +function toDto(record: WalkthroughCheckins) { + const plain = record.get({ plain: true }); + + return { + id: plain.id, + teacher_name: plain.teacher_name, + classroom: plain.classroom, + director_name: plain.director_name, + check_in_date: plain.check_in_date, + check_in_time: plain.check_in_time, + attitude_rating: plain.attitude_rating, + attitude_comment: plain.attitude_comment, + classroom_management_rating: plain.classroom_management_rating, + classroom_management_comment: plain.classroom_management_comment, + cleanliness_rating: plain.cleanliness_rating, + cleanliness_comment: plain.cleanliness_comment, + vibes_rating: plain.vibes_rating, + vibes_comment: plain.vibes_comment, + team_dynamics_rating: plain.team_dynamics_rating, + team_dynamics_comment: plain.team_dynamics_comment, + emergency_exit_rating: plain.emergency_exit_rating, + emergency_exit_comment: plain.emergency_exit_comment, + lesson_plan_rating: plain.lesson_plan_rating, + lesson_plan_comment: plain.lesson_plan_comment, + overall_notes: plain.overall_notes, + organizationId: plain.organizationId, + campusId: plain.campusId, + createdById: plain.createdById, + updatedById: plain.updatedById, + createdAt: plain.createdAt, + updatedAt: plain.updatedAt, + }; +} + +/** Restricts non-tenant-wide roles to their own campus. Uses the module-local + * `getCampusId` (staff-campus only), so it stays local. */ +function campusScope(currentUser?: CurrentUser): { campusId?: string } { + if (hasRoleAccess(currentUser, WALKTHROUGH_TENANT_WIDE_ROLE_NAMES)) { + return {}; + } + + const campusId = getCampusId(currentUser); + return campusId ? { campusId } : {}; +} + +class WalkthroughCheckinsService { + static async list(filter: WalkthroughFilter, currentUser?: CurrentUser) { + assertCanManage(currentUser); + const { limit, offset } = resolvePagination(filter.limit, filter.page); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const result = await db.walkthrough_checkins.findAndCountAll({ + where: { + ...orgFilter, + ...campusScope(currentUser), + ...(filter.teacher_name ? { teacher_name: filter.teacher_name } : {}), + }, + order: [ + ['check_in_date', 'desc'], + ['createdAt', 'desc'], + ], + limit, + offset, + }); + + return { + rows: result.rows.map(toDto), + count: result.count, + }; + } + + static async create(data: WalkthroughInput, currentUser?: CurrentUser) { + assertCanManage(currentUser); + assertValidCheckin(data); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + + return withTransaction(async (transaction) => { + const created = await db.walkthrough_checkins.create( + { + teacher_name: data.teacher_name.trim(), + classroom: data.classroom.trim(), + director_name: data.director_name.trim(), + check_in_date: data.check_in_date.trim(), + check_in_time: data.check_in_time.trim(), + attitude_rating: data.attitude_rating, + attitude_comment: nullableString(data.attitude_comment), + classroom_management_rating: data.classroom_management_rating, + classroom_management_comment: nullableString( + data.classroom_management_comment, + ), + cleanliness_rating: data.cleanliness_rating, + cleanliness_comment: nullableString(data.cleanliness_comment), + vibes_rating: data.vibes_rating, + vibes_comment: nullableString(data.vibes_comment), + team_dynamics_rating: data.team_dynamics_rating, + team_dynamics_comment: nullableString(data.team_dynamics_comment), + emergency_exit_rating: data.emergency_exit_rating, + emergency_exit_comment: nullableString(data.emergency_exit_comment), + lesson_plan_rating: data.lesson_plan_rating, + lesson_plan_comment: nullableString(data.lesson_plan_comment), + overall_notes: nullableString(data.overall_notes), + organizationId, + campusId: getCampusId(currentUser), + createdById: requireUserId(currentUser), + updatedById: currentUser?.id ?? null, + }, + { transaction }, + ); + + return toDto(created); + }); + } + + static async remove(id: string, currentUser?: CurrentUser) { + assertCanManage(currentUser); + + const organizationId = getOrganizationIdOrGlobal(currentUser); + const orgFilter = organizationId ? { organizationId } : {}; + + const deletedCount = await db.walkthrough_checkins.destroy({ + where: { + id, + ...orgFilter, + ...campusScope(currentUser), + }, + }); + + return { deletedCount }; + } +} + +export default WalkthroughCheckinsService; diff --git a/backend/src/services/walkthrough_checkins.types.ts b/backend/src/services/walkthrough_checkins.types.ts new file mode 100644 index 0000000..a8da590 --- /dev/null +++ b/backend/src/services/walkthrough_checkins.types.ts @@ -0,0 +1,28 @@ +export interface WalkthroughInput { + teacher_name: string; + classroom: string; + director_name: string; + check_in_date: string; + check_in_time: string; + attitude_rating: number; + classroom_management_rating: number; + cleanliness_rating: number; + vibes_rating: number; + team_dynamics_rating: number; + emergency_exit_rating: number; + lesson_plan_rating: number; + attitude_comment?: unknown; + classroom_management_comment?: unknown; + cleanliness_comment?: unknown; + vibes_comment?: unknown; + team_dynamics_comment?: unknown; + emergency_exit_comment?: unknown; + lesson_plan_comment?: unknown; + overall_notes?: unknown; +} + +export interface WalkthroughFilter { + teacher_name?: string; + limit?: number | string; + page?: number | string; +} diff --git a/backend/src/shared/architecture/import-boundaries.test.ts b/backend/src/shared/architecture/import-boundaries.test.ts new file mode 100644 index 0000000..fe87a50 --- /dev/null +++ b/backend/src/shared/architecture/import-boundaries.test.ts @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Enforces the backend layer import direction (see + * `backend/docs/backend-architecture-refactor-plan.md`): + * + * Route -> Controller -> Service (BLL) -> Repository/Model (DAL) -> DB + * shared/constants/types/errors are cross-cutting and depend on no layer. + * + * Hard invariants assert zero violations. Debt ceilings cap known violations so + * they cannot grow and are ratcheted down to 0 as the refactor phases land. + */ + +const SRC = path.resolve(import.meta.dirname, '../..'); + +function listTs(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules') continue; + out.push(...listTs(full)); + } else if ( + entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + !entry.name.endsWith('.d.ts') + ) { + out.push(full); + } + } + return out; +} + +const FILES = listTs(SRC).map((file) => ({ + rel: path.relative(SRC, file).split(path.sep).join('/'), + imports: [...readFileSync(file, 'utf8').matchAll(/from '([^']+)'/g)].map( + (m) => m[1], + ), +})); + +const API_LAYER = ['@/api/', '@/routes/', '@/middlewares/']; + +function violators(layerPrefixes: string[], banned: string[]): string[] { + return FILES.filter( + (f) => + layerPrefixes.some((p) => f.rel.startsWith(p)) && + f.imports.some((i) => banned.some((b) => i === b || i.startsWith(b))), + ).map((f) => f.rel); +} + +// ---- Hard invariants (must be 0) ---- + +test('DAL models import no BLL, API layer, or Express', () => { + assert.deepEqual( + violators(['db/models/'], ['@/services/', 'express', ...API_LAYER]), + [], + ); +}); + +test('cross-cutting code (shared/*) imports no layer', () => { + assert.deepEqual( + violators( + ['shared/'], + ['@/api/', '@/routes/', '@/middlewares/', '@/services/', '@/db/'], + ), + [], + ); +}); + +test('DAL does not import the API layer', () => { + assert.deepEqual(violators(['db/'], API_LAYER), []); +}); + +// ---- Hard invariant earned in Phase 1-2 ---- + +test('API layer (routes, controllers) does not import the DAL', () => { + assert.deepEqual( + violators(['routes/', 'api/controllers/'], ['@/db/api', '@/db/models']), + [], + ); +}); + +// ---- Debt ceilings (must not grow; lowered to 0 across phases) ---- + +test('BLL depends on HTTP only in the streaming/session edge cases', () => { + // Remaining: `services/file.ts` (upload/download streaming) and + // `services/auth.ts` (session IP/UA + cookies). To be revisited. + const v = violators(['services/'], ['express', ...API_LAYER]); + assert.ok( + v.length <= 2, + `services depending on HTTP grew to ${v.length} (>2: only file/auth remain)`, + ); +}); + +test('DAL depending on BLL — ceiling (→ 0)', () => { + const v = violators(['db/api/'], ['@/services/']); + assert.ok( + v.length <= 1, + `repositories importing services grew to ${v.length} (>1)`, + ); +}); diff --git a/backend/src/shared/config/index.ts b/backend/src/shared/config/index.ts new file mode 100644 index 0000000..c4e707a --- /dev/null +++ b/backend/src/shared/config/index.ts @@ -0,0 +1,230 @@ +import os from 'os'; +import './load-env'; + +import { + AUTH_COOKIE_NAME, + AUTH_COOKIE_PATH, + AUTH_REFRESH_COOKIE_NAME, + AUTH_PROVIDERS, + BCRYPT_SALT_ROUNDS, + DEFAULT_AUTH_COOKIE_SAME_SITE, + JWT_EXPIRES_IN_MS, + REFRESH_TOKEN_BYTES, + REFRESH_TOKEN_EXPIRES_IN_MS, + REFRESH_TOKEN_HASH_ALGORITHM, +} from '@/shared/constants/auth'; +import { + DEFAULT_DEV_API_PORT, + DEFAULT_DEV_UI_PORT, + DEFAULT_DEV_HOST, + DEFAULT_EMAIL_FROM, + DEFAULT_EMAIL_HOST, + DEFAULT_EMAIL_PORT, +} from '@/shared/constants/app'; +import { GENERATED_ROLE_NAMES } from '@/shared/constants/roles'; + +function requiredEnv(name: string): string { + const value = process.env[name]; + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function readBooleanEnv(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + + if (value === undefined || value === '') { + return defaultValue; + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + throw new Error(`Invalid boolean environment variable: ${name}`); +} + +function readNumberEnv(name: string, defaultValue: number): number { + const value = process.env[name]; + + if (value === undefined || value === '') { + return defaultValue; + } + + const parsedValue = Number(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + throw new Error(`Invalid positive number environment variable: ${name}`); + } + + return parsedValue; +} + +function readListEnv(name: string, defaultValue: string[]): string[] { + const value = process.env[name]; + + if (!value) { + return defaultValue; + } + + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeSameSite(value: string): 'strict' | 'lax' | 'none' { + const normalizedValue = value.toLowerCase(); + + if ( + normalizedValue !== 'strict' && + normalizedValue !== 'lax' && + normalizedValue !== 'none' + ) { + throw new Error(`Invalid AUTH_COOKIE_SAME_SITE value: ${value}`); + } + + return normalizedValue; +} + +const isProductionLike = + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage'; +const isProduction = process.env.NODE_ENV === 'production'; +const authCookieSameSite = normalizeSameSite( + process.env.AUTH_COOKIE_SAME_SITE || DEFAULT_AUTH_COOKIE_SAME_SITE, +); +const authCookieSecure = readBooleanEnv('AUTH_COOKIE_SECURE', isProductionLike); +const authCookieMaxAgeMs = readNumberEnv('AUTH_COOKIE_MAX_AGE_MS', JWT_EXPIRES_IN_MS); +const defaultApiOrigin = `${DEFAULT_DEV_HOST}:${process.env.PORT || DEFAULT_DEV_API_PORT}`; +const defaultUiOrigin = `${process.env.UI_HOST || DEFAULT_DEV_HOST}${ + process.env.UI_PORT || DEFAULT_DEV_UI_PORT + ? `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}` + : '' +}`; +const defaultAllowedOrigins = [defaultUiOrigin, defaultApiOrigin]; +const allowedOrigins = [ + ...new Set(readListEnv('ALLOWED_ORIGINS', defaultAllowedOrigins)), +]; +// The staging preview (dev_stage) runs behind a tunnel on a dynamic domain that +// the platform does not inject as ALLOWED_ORIGINS. When the list is not set +// explicitly there, reflect the request origin instead of rejecting it. Strict +// production still requires an explicit allow-list (enforced below). +const allowAllOrigins = + isProductionLike && !isProduction && !process.env.ALLOWED_ORIGINS; + +if (authCookieSameSite === 'none' && !authCookieSecure) { + throw new Error( + 'AUTH_COOKIE_SECURE must be true when AUTH_COOKIE_SAME_SITE is none', + ); +} + +if (isProductionLike && !authCookieSecure) { + throw new Error( + 'AUTH_COOKIE_SECURE must be true in production-like environments', + ); +} + +if (isProduction && !process.env.ALLOWED_ORIGINS) { + throw new Error('ALLOWED_ORIGINS must be configured in production'); +} + +const remote = ''; +const port = isProduction ? '' : process.env.PORT || DEFAULT_DEV_API_PORT; +const serverPort = + process.env.NODE_ENV === 'dev_stage' + ? DEFAULT_DEV_UI_PORT + : process.env.PORT || DEFAULT_DEV_API_PORT; +const hostUI = isProduction ? '' : process.env.UI_HOST || DEFAULT_DEV_HOST; +const portUI = isProduction ? '' : process.env.UI_PORT || DEFAULT_DEV_UI_PORT; +const portUIProd = isProduction + ? '' + : `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}`; +const swaggerUI = isProduction + ? '' + : process.env.SWAGGER_HOST || DEFAULT_DEV_HOST; +const swaggerPort = isProduction + ? '' + : `:${process.env.SWAGGER_PORT || DEFAULT_DEV_API_PORT}`; +const host = isProduction ? remote : DEFAULT_DEV_HOST; +const apiUrl = `${host}${port ? `:${port}` : ''}/api`; +const swaggerUrl = `${swaggerUI}${swaggerPort}`; +const uiUrl = `${hostUI}${portUI ? `:${portUI}` : ''}/#`; +const backUrl = `${hostUI}${portUI ? `:${portUI}` : ''}`; + +const config = { + gcloud: { + bucket: process.env.GCLOUD_BUCKET || '', + hash: process.env.GCLOUD_HASH || '', + }, + bcrypt: { + saltRounds: BCRYPT_SALT_ROUNDS, + }, + providers: AUTH_PROVIDERS, + auth: { + allowedOrigins, + allowAllOrigins, + accessCookieName: process.env.AUTH_ACCESS_COOKIE_NAME || AUTH_COOKIE_NAME, + accessTokenMaxAgeMs: authCookieMaxAgeMs, + cookieDomain: process.env.AUTH_COOKIE_DOMAIN || undefined, + cookiePath: AUTH_COOKIE_PATH, + cookieSameSite: authCookieSameSite, + cookieSecure: authCookieSecure, + refreshCookieName: + process.env.AUTH_REFRESH_COOKIE_NAME || AUTH_REFRESH_COOKIE_NAME, + refreshTokenBytes: REFRESH_TOKEN_BYTES, + refreshTokenHashAlgorithm: REFRESH_TOKEN_HASH_ALGORITHM, + refreshTokenMaxAgeMs: readNumberEnv( + 'AUTH_REFRESH_TOKEN_MAX_AGE_MS', + REFRESH_TOKEN_EXPIRES_IN_MS, + ), + }, + secret_key: requiredEnv('SECRET_KEY'), + remote, + port, + serverPort, + hostUI, + portUI, + portUIProd, + swaggerUI, + swaggerPort, + google: { + clientId: process.env.GOOGLE_CLIENT_ID || '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + }, + microsoft: { + clientId: process.env.MS_CLIENT_ID || '', + clientSecret: process.env.MS_CLIENT_SECRET || '', + }, + uploadDir: os.tmpdir(), + email: { + from: process.env.EMAIL_FROM || DEFAULT_EMAIL_FROM, + host: process.env.EMAIL_HOST || DEFAULT_EMAIL_HOST, + port: Number(process.env.EMAIL_PORT || DEFAULT_EMAIL_PORT), + auth: { + user: process.env.EMAIL_USER || '', + pass: process.env.EMAIL_PASS, + }, + tls: { + rejectUnauthorized: false, + }, + }, + roles: { + super_admin: GENERATED_ROLE_NAMES.SUPER_ADMIN, + admin: GENERATED_ROLE_NAMES.ADMIN, + user: GENERATED_ROLE_NAMES.FINANCE_OFFICER, + }, + host, + apiUrl, + swaggerUrl, + uiUrl, + backUrl, +}; + +export default config; diff --git a/backend/src/config/load-env.js b/backend/src/shared/config/load-env.ts similarity index 80% rename from backend/src/config/load-env.js rename to backend/src/shared/config/load-env.ts index a02af68..61875a6 100644 --- a/backend/src/config/load-env.js +++ b/backend/src/shared/config/load-env.ts @@ -1,9 +1,15 @@ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; +const __dirname = import.meta.dirname; const ENV_FILE = path.resolve(__dirname, '..', '..', '.env'); -function parseEnvLine(line) { +interface EnvEntry { + key: string; + value: string; +} + +function parseEnvLine(line: string): EnvEntry | null { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { @@ -33,7 +39,7 @@ function parseEnvLine(line) { return { key, value }; } -function loadEnvFile() { +function loadEnvFile(): void { if (!fs.existsSync(ENV_FILE)) { return; } diff --git a/backend/src/shared/constants/app.ts b/backend/src/shared/constants/app.ts new file mode 100644 index 0000000..620943c --- /dev/null +++ b/backend/src/shared/constants/app.ts @@ -0,0 +1,9 @@ +export const DEFAULT_DEV_API_PORT = '8080'; +export const DEFAULT_DEV_UI_PORT = '3000'; +export const DEFAULT_DEV_HOST = 'http://localhost'; +export const DEFAULT_EMAIL_FROM = 'School Chain Manager '; +export const DEFAULT_EMAIL_HOST = 'email-smtp.us-east-1.amazonaws.com'; +export const DEFAULT_EMAIL_PORT = 587; +export const DEFAULT_DEV_DB_HOST = 'localhost'; +export const DEFAULT_DEV_DB_NAME = 'db_school_chain_manager'; +export const DEFAULT_DEV_DB_USER = 'postgres'; diff --git a/backend/src/shared/constants/auth.ts b/backend/src/shared/constants/auth.ts new file mode 100644 index 0000000..1144492 --- /dev/null +++ b/backend/src/shared/constants/auth.ts @@ -0,0 +1,30 @@ +export const AUTH_PROVIDERS = Object.freeze({ + LOCAL: 'local', + GOOGLE: 'google', + MICROSOFT: 'microsoft', +}); + +export const BCRYPT_SALT_ROUNDS = 12; +export const JWT_EXPIRES_IN = '15m'; +export const JWT_EXPIRES_IN_MS = 15 * 60 * 1000; +export const AUTH_COOKIE_NAME = 'school_chain_session'; +export const AUTH_REFRESH_COOKIE_NAME = 'school_chain_refresh'; +export const AUTH_COOKIE_PATH = '/'; +export const REFRESH_TOKEN_EXPIRES_IN_MS = 14 * 24 * 60 * 60 * 1000; +export const REFRESH_TOKEN_BYTES = 64; +export const REFRESH_TOKEN_HASH_ALGORITHM = 'sha256'; +/** One-time email-verification / password-reset token: random byte length and TTL. */ +export const EMAIL_ACTION_TOKEN_BYTES = 20; +export const EMAIL_ACTION_TOKEN_TTL_MS = 360000; +export const AUTH_COOKIE_SAME_SITE_VALUES = Object.freeze([ + 'strict', + 'lax', + 'none', +]); +export const DEFAULT_AUTH_COOKIE_SAME_SITE = 'lax'; +export const UNSAFE_HTTP_METHODS = Object.freeze([ + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +]); diff --git a/backend/src/constants/campus-attendance.js b/backend/src/shared/constants/campus-attendance.ts similarity index 52% rename from backend/src/constants/campus-attendance.js rename to backend/src/shared/constants/campus-attendance.ts index 8817f91..0fb9b65 100644 --- a/backend/src/constants/campus-attendance.js +++ b/backend/src/shared/constants/campus-attendance.ts @@ -1,33 +1,39 @@ -const { +import { GENERATED_ROLE_NAMES, GENERATED_ROLE_TO_PRODUCT_ROLE, PRODUCT_ROLE_VALUES, STAFF_TYPE_TO_PRODUCT_ROLE, -} = require('./roles'); + type ProductRoleValue, +} from './roles'; -const CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ +export const CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, ]); -const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([ +export const CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES = Object.freeze([ ...CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, GENERATED_ROLE_NAMES.CAMPUS_MANAGER, GENERATED_ROLE_NAMES.FINANCE_OFFICER, ]); -const CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES = Object.freeze([ +export const CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES = Object.freeze([ PRODUCT_ROLE_VALUES.OFFICE, PRODUCT_ROLE_VALUES.DIRECTOR, PRODUCT_ROLE_VALUES.SUPERINTENDENT, ]); -const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120; -const CAMPUS_ATTENDANCE_MAX_LIMIT = 366; +export const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120; +export const CAMPUS_ATTENDANCE_MAX_LIMIT = 366; -function normalizeCampusKey(value) { +interface ProductRoleUser { + app_role?: { name?: string | null } | null; + staff_user?: Array<{ staff_type?: string | null }> | null; +} + +export function normalizeCampusKey(value: unknown): string | null { if (typeof value !== 'string' || value.trim().length === 0) { return null; } @@ -37,27 +43,24 @@ function normalizeCampusKey(value) { return normalized || null; } -function getProductRole(currentUser) { +export function getProductRole( + currentUser: ProductRoleUser | null | undefined, +): ProductRoleValue { const roleName = currentUser?.app_role?.name; - const staffProfile = Array.isArray(currentUser?.staff_user) ? currentUser.staff_user[0] : null; + const staffProfile = Array.isArray(currentUser?.staff_user) + ? currentUser.staff_user[0] + : null; if (roleName && GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]) { return GENERATED_ROLE_TO_PRODUCT_ROLE[roleName]; } - if (staffProfile?.staff_type && STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type]) { + if ( + staffProfile?.staff_type && + STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type] + ) { return STAFF_TYPE_TO_PRODUCT_ROLE[staffProfile.staff_type]; } return PRODUCT_ROLE_VALUES.TEACHER; } - -module.exports = { - CAMPUS_ATTENDANCE_DEFAULT_LIMIT, - CAMPUS_ATTENDANCE_MANAGER_ROLE_NAMES, - CAMPUS_ATTENDANCE_MANAGE_PRODUCT_ROLES, - CAMPUS_ATTENDANCE_MAX_LIMIT, - CAMPUS_ATTENDANCE_TENANT_WIDE_ROLE_NAMES, - getProductRole, - normalizeCampusKey, -}; diff --git a/backend/src/constants/campuses.js b/backend/src/shared/constants/campuses.ts similarity index 96% rename from backend/src/constants/campuses.js rename to backend/src/shared/constants/campuses.ts index 51f990c..9935bc0 100644 --- a/backend/src/constants/campuses.js +++ b/backend/src/shared/constants/campuses.ts @@ -1,4 +1,4 @@ -const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ +export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ { id: '7e15d693-3f7c-4bc6-a399-8345002af8cf', name: 'Tigers Campus', @@ -90,7 +90,3 @@ const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ importHash: 'product-campus-grizzlies', }, ]); - -module.exports = { - PRODUCT_CAMPUS_SEED_ROWS, -}; diff --git a/backend/src/shared/constants/communications.ts b/backend/src/shared/constants/communications.ts new file mode 100644 index 0000000..36df652 --- /dev/null +++ b/backend/src/shared/constants/communications.ts @@ -0,0 +1,60 @@ +import { GENERATED_ROLE_NAMES } from './roles'; + +export const COMMUNICATION_CHANNELS = Object.freeze({ + IN_APP: 'in_app', +}); + +export const COMMUNICATION_AUDIENCES = Object.freeze({ + GUARDIANS: 'guardians', + STAFF: 'staff', +}); + +export const COMMUNICATION_STATUSES = Object.freeze({ + SENT: 'sent', +}); + +export const COMMUNICATION_RECIPIENT_TYPES = Object.freeze({ + GUARDIAN: 'guardian', +}); + +export const COMMUNICATION_EVENT_TYPES = Object.freeze({ + MEETING: 'meeting', + DRILL: 'drill', + EVENT: 'event', + DEADLINE: 'deadline', +}); + +export type CommunicationEventType = 'meeting' | 'drill' | 'event' | 'deadline'; + +export const COMMUNICATION_EVENT_TYPE_VALUES: readonly CommunicationEventType[] = + ['meeting', 'drill', 'event', 'deadline']; + +export type ParentMessageCategory = + | 'behavior' + | 'event' + | 'progress' + | 'general'; + +export const PARENT_MESSAGE_CATEGORY_VALUES: readonly ParentMessageCategory[] = [ + 'behavior', + 'event', + 'progress', + 'general', +]; + +export const DEFAULT_PARENT_MESSAGE_CATEGORY: ParentMessageCategory = 'general'; + +export const COMMUNICATION_MANAGER_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +export const COMMUNICATION_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, +]); diff --git a/backend/src/constants/frame.js b/backend/src/shared/constants/content-catalog.ts similarity index 55% rename from backend/src/constants/frame.js rename to backend/src/shared/constants/content-catalog.ts index b454d42..a62e30f 100644 --- a/backend/src/constants/frame.js +++ b/backend/src/shared/constants/content-catalog.ts @@ -1,13 +1,9 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); +import { GENERATED_ROLE_NAMES } from './roles'; -const FRAME_EDITOR_ROLE_NAMES = Object.freeze([ +export const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, GENERATED_ROLE_NAMES.CAMPUS_MANAGER, ]); - -module.exports = { - FRAME_EDITOR_ROLE_NAMES, -}; diff --git a/backend/src/shared/constants/database.ts b/backend/src/shared/constants/database.ts new file mode 100644 index 0000000..f7fc20e --- /dev/null +++ b/backend/src/shared/constants/database.ts @@ -0,0 +1,5 @@ +/** + * Per-row `createdAt` offset (in milliseconds) applied during bulk imports so + * that inserted rows keep a stable, monotonically increasing insertion order. + */ +export const BULK_IMPORT_TIMESTAMP_STEP_MS = 1000; diff --git a/backend/src/constants/personality.js b/backend/src/shared/constants/frame.ts similarity index 53% rename from backend/src/constants/personality.js rename to backend/src/shared/constants/frame.ts index fee96aa..606a1a4 100644 --- a/backend/src/constants/personality.js +++ b/backend/src/shared/constants/frame.ts @@ -1,13 +1,9 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); +import { GENERATED_ROLE_NAMES } from './roles'; -const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([ +export const FRAME_EDITOR_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, GENERATED_ROLE_NAMES.CAMPUS_MANAGER, ]); - -module.exports = { - PERSONALITY_REPORT_ROLE_NAMES, -}; diff --git a/backend/src/shared/constants/pagination.ts b/backend/src/shared/constants/pagination.ts new file mode 100644 index 0000000..728ef1d --- /dev/null +++ b/backend/src/shared/constants/pagination.ts @@ -0,0 +1,25 @@ +/** Shared list pagination defaults (page size matches the ref-frontend grids). */ +export const DEFAULT_PAGE_SIZE = 10; +export const MAX_PAGE_SIZE = 100; + +/** + * Resolves a `{ limit, offset }` for a list query from raw `limit`/`page` query + * values. Falls back to {@link DEFAULT_PAGE_SIZE} and caps at + * {@link MAX_PAGE_SIZE}. `page` is zero-based. + */ +export function resolvePagination( + rawLimit?: unknown, + rawPage?: unknown, +): { limit: number; offset: number } { + const parsedLimit = Number(rawLimit); + const limit = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(Math.floor(parsedLimit), MAX_PAGE_SIZE) + : DEFAULT_PAGE_SIZE; + + const parsedPage = Number(rawPage); + const page = + Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 0; + + return { limit, offset: page * limit }; +} diff --git a/backend/src/constants/safety-quiz.js b/backend/src/shared/constants/personality.ts similarity index 53% rename from backend/src/constants/safety-quiz.js rename to backend/src/shared/constants/personality.ts index 57c0342..be57a9b 100644 --- a/backend/src/constants/safety-quiz.js +++ b/backend/src/shared/constants/personality.ts @@ -1,13 +1,9 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); +import { GENERATED_ROLE_NAMES } from './roles'; -const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([ +export const PERSONALITY_REPORT_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, GENERATED_ROLE_NAMES.CAMPUS_MANAGER, ]); - -module.exports = { - SAFETY_QUIZ_REPORT_ROLE_NAMES, -}; diff --git a/backend/src/shared/constants/roles.ts b/backend/src/shared/constants/roles.ts new file mode 100644 index 0000000..b1596d3 --- /dev/null +++ b/backend/src/shared/constants/roles.ts @@ -0,0 +1,46 @@ +export const GENERATED_ROLE_NAMES = Object.freeze({ + SUPER_ADMIN: 'Super Administrator', + ADMIN: 'Administrator', + PLATFORM_OWNER: 'Platform Owner', + TENANT_DIRECTOR: 'Tenant Director', + CAMPUS_MANAGER: 'Campus Manager', + ACADEMIC_COORDINATOR: 'Academic Coordinator', + FINANCE_OFFICER: 'Finance Officer', +}); + +/** Seeded roles referenced by name in code (distinct from product/staff roles). */ +export const SPECIAL_ROLE_NAMES = Object.freeze({ + /** Fallback role used for requests without an assigned role. */ + PUBLIC: 'Public', + /** Default role assigned to a newly created user. */ + DEFAULT_USER: 'User', +}); + +export const PRODUCT_ROLE_VALUES = Object.freeze({ + TEACHER: 'teacher', + PARA: 'para', + OFFICE: 'office', + DIRECTOR: 'director', + SUPERINTENDENT: 'superintendent', +}); + +export type ProductRoleValue = + (typeof PRODUCT_ROLE_VALUES)[keyof typeof PRODUCT_ROLE_VALUES]; + +export const GENERATED_ROLE_TO_PRODUCT_ROLE: Record = + Object.freeze({ + [GENERATED_ROLE_NAMES.SUPER_ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.ADMIN]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.PLATFORM_OWNER]: PRODUCT_ROLE_VALUES.SUPERINTENDENT, + [GENERATED_ROLE_NAMES.TENANT_DIRECTOR]: PRODUCT_ROLE_VALUES.DIRECTOR, + [GENERATED_ROLE_NAMES.CAMPUS_MANAGER]: PRODUCT_ROLE_VALUES.DIRECTOR, + [GENERATED_ROLE_NAMES.ACADEMIC_COORDINATOR]: PRODUCT_ROLE_VALUES.TEACHER, + [GENERATED_ROLE_NAMES.FINANCE_OFFICER]: PRODUCT_ROLE_VALUES.OFFICE, + }); + +export const STAFF_TYPE_TO_PRODUCT_ROLE: Record = + Object.freeze({ + teacher: PRODUCT_ROLE_VALUES.TEACHER, + admin: PRODUCT_ROLE_VALUES.OFFICE, + support: PRODUCT_ROLE_VALUES.PARA, + }); diff --git a/backend/src/constants/content-catalog.js b/backend/src/shared/constants/safety-quiz.ts similarity index 52% rename from backend/src/constants/content-catalog.js rename to backend/src/shared/constants/safety-quiz.ts index c58673c..c987df6 100644 --- a/backend/src/constants/content-catalog.js +++ b/backend/src/shared/constants/safety-quiz.ts @@ -1,13 +1,9 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); +import { GENERATED_ROLE_NAMES } from './roles'; -const CONTENT_CATALOG_MANAGER_ROLE_NAMES = Object.freeze([ +export const SAFETY_QUIZ_REPORT_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, GENERATED_ROLE_NAMES.CAMPUS_MANAGER, ]); - -module.exports = { - CONTENT_CATALOG_MANAGER_ROLE_NAMES, -}; diff --git a/backend/src/shared/constants/staff-attendance.ts b/backend/src/shared/constants/staff-attendance.ts new file mode 100644 index 0000000..852a0d3 --- /dev/null +++ b/backend/src/shared/constants/staff-attendance.ts @@ -0,0 +1,24 @@ +import { GENERATED_ROLE_NAMES } from './roles'; + +export const STAFF_ATTENDANCE_STATUSES = Object.freeze({ + PRESENT: 'present', + LATE: 'late', + ABSENT: 'absent', +}); + +export const STAFF_ATTENDANCE_REPORT_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, + GENERATED_ROLE_NAMES.TENANT_DIRECTOR, + GENERATED_ROLE_NAMES.CAMPUS_MANAGER, +]); + +export const STAFF_ATTENDANCE_TENANT_WIDE_ROLE_NAMES = Object.freeze([ + GENERATED_ROLE_NAMES.SUPER_ADMIN, + GENERATED_ROLE_NAMES.ADMIN, + GENERATED_ROLE_NAMES.PLATFORM_OWNER, +]); + +export const STAFF_ATTENDANCE_DEFAULT_LIMIT = 90; +export const STAFF_ATTENDANCE_MAX_LIMIT = 366; diff --git a/backend/src/shared/constants/staff.ts b/backend/src/shared/constants/staff.ts new file mode 100644 index 0000000..e4849d0 --- /dev/null +++ b/backend/src/shared/constants/staff.ts @@ -0,0 +1,4 @@ +/** Lifecycle status values for a staff record (the `staff.status` column). */ +export const STAFF_STATUSES = Object.freeze({ + ACTIVE: 'active', +}); diff --git a/backend/src/shared/constants/user-progress.ts b/backend/src/shared/constants/user-progress.ts new file mode 100644 index 0000000..b7fab74 --- /dev/null +++ b/backend/src/shared/constants/user-progress.ts @@ -0,0 +1,13 @@ +export const USER_PROGRESS_TYPES = Object.freeze({ + SIGN_LEARNED: 'sign_learned', + ZONE_CHECKIN: 'zone_checkin', +}); + +export type UserProgressType = 'sign_learned' | 'zone_checkin'; + +export const USER_PROGRESS_TYPE_VALUES: readonly UserProgressType[] = [ + 'sign_learned', + 'zone_checkin', +]; + +export const ZONE_CHECKIN_ITEM_ID = 'current'; diff --git a/backend/src/shared/constants/validation.ts b/backend/src/shared/constants/validation.ts new file mode 100644 index 0000000..5b31b21 --- /dev/null +++ b/backend/src/shared/constants/validation.ts @@ -0,0 +1,2 @@ +/** Matches an ISO calendar date string: `YYYY-MM-DD`. */ +export const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; diff --git a/backend/src/constants/walkthrough.js b/backend/src/shared/constants/walkthrough.ts similarity index 56% rename from backend/src/constants/walkthrough.js rename to backend/src/shared/constants/walkthrough.ts index d9e678a..0b2973d 100644 --- a/backend/src/constants/walkthrough.js +++ b/backend/src/shared/constants/walkthrough.ts @@ -1,6 +1,6 @@ -const { GENERATED_ROLE_NAMES } = require('./roles'); +import { GENERATED_ROLE_NAMES } from './roles'; -const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ +export const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, @@ -8,14 +8,9 @@ const WALKTHROUGH_MANAGER_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.CAMPUS_MANAGER, ]); -const WALKTHROUGH_TENANT_WIDE_ROLE_NAMES = Object.freeze([ +export const WALKTHROUGH_TENANT_WIDE_ROLE_NAMES = Object.freeze([ GENERATED_ROLE_NAMES.SUPER_ADMIN, GENERATED_ROLE_NAMES.ADMIN, GENERATED_ROLE_NAMES.PLATFORM_OWNER, GENERATED_ROLE_NAMES.TENANT_DIRECTOR, ]); - -module.exports = { - WALKTHROUGH_MANAGER_ROLE_NAMES, - WALKTHROUGH_TENANT_WIDE_ROLE_NAMES, -}; diff --git a/backend/src/shared/csv.ts b/backend/src/shared/csv.ts new file mode 100644 index 0000000..ee7befe --- /dev/null +++ b/backend/src/shared/csv.ts @@ -0,0 +1,6 @@ +import { json2csv } from 'json-2-csv'; + +/** Serializes rows to CSV, emitting only the given column fields. */ +export function toCsv(rows: object[], fields: string[]): string { + return json2csv(rows, { keys: fields, emptyFieldValue: '' }); +} diff --git a/backend/src/shared/errors/app-error.ts b/backend/src/shared/errors/app-error.ts new file mode 100644 index 0000000..f9a2bb4 --- /dev/null +++ b/backend/src/shared/errors/app-error.ts @@ -0,0 +1,30 @@ +export interface AppErrorOptions { + /** Machine-readable error code (the notification key), sent to the client. */ + code?: string; + /** Optional structured payload (e.g. field-level validation info). */ + details?: unknown; + /** The underlying error, kept for logging (never sent to the client). */ + cause?: unknown; +} + +/** + * Base class for all expected (operational) application errors. The centralized + * error middleware turns an `AppError` into a `{ message, code?, details? }` + * JSON body with `status` as the HTTP status — the shape the frontend + * `ApiError` consumes. Unexpected/native errors are treated as 500s. + */ +export class AppError extends Error { + readonly status: number; + readonly code?: string; + readonly details?: unknown; + + constructor(status: number, message: string, options: AppErrorOptions = {}) { + super(message, options.cause !== undefined ? { cause: options.cause } : {}); + this.name = new.target.name; + this.status = status; + this.code = options.code; + this.details = options.details; + } +} + +export default AppError; diff --git a/backend/src/shared/errors/forbidden.ts b/backend/src/shared/errors/forbidden.ts new file mode 100644 index 0000000..47187d8 --- /dev/null +++ b/backend/src/shared/errors/forbidden.ts @@ -0,0 +1,24 @@ +import AppError from '@/shared/errors/app-error'; +import { + getNotification, + isNotification, +} from '@/shared/notifications/helpers'; + +const DEFAULT_CODE = 'errors.forbidden.message'; + +class ForbiddenError extends AppError { + constructor(messageCode?: string) { + super(403, ForbiddenError.resolveMessage(messageCode), { + code: messageCode ?? DEFAULT_CODE, + }); + } + + private static resolveMessage(messageCode?: string): string { + if (messageCode && isNotification(messageCode)) { + return getNotification(messageCode); + } + return getNotification(DEFAULT_CODE); + } +} + +export default ForbiddenError; diff --git a/backend/src/shared/errors/not-found.ts b/backend/src/shared/errors/not-found.ts new file mode 100644 index 0000000..bc270d8 --- /dev/null +++ b/backend/src/shared/errors/not-found.ts @@ -0,0 +1,24 @@ +import AppError from '@/shared/errors/app-error'; +import { + getNotification, + isNotification, +} from '@/shared/notifications/helpers'; + +const DEFAULT_CODE = 'errors.notFound.message'; + +class NotFoundError extends AppError { + constructor(messageCode?: string) { + super(404, NotFoundError.resolveMessage(messageCode), { + code: messageCode ?? DEFAULT_CODE, + }); + } + + private static resolveMessage(messageCode?: string): string { + if (messageCode && isNotification(messageCode)) { + return getNotification(messageCode); + } + return getNotification(DEFAULT_CODE); + } +} + +export default NotFoundError; diff --git a/backend/src/shared/errors/unauthorized.ts b/backend/src/shared/errors/unauthorized.ts new file mode 100644 index 0000000..ae312aa --- /dev/null +++ b/backend/src/shared/errors/unauthorized.ts @@ -0,0 +1,24 @@ +import AppError from '@/shared/errors/app-error'; +import { + getNotification, + isNotification, +} from '@/shared/notifications/helpers'; + +const DEFAULT_CODE = 'errors.unauthorized.message'; + +class UnauthorizedError extends AppError { + constructor(messageCode?: string) { + super(401, UnauthorizedError.resolveMessage(messageCode), { + code: messageCode ?? DEFAULT_CODE, + }); + } + + private static resolveMessage(messageCode?: string): string { + if (messageCode && isNotification(messageCode)) { + return getNotification(messageCode); + } + return getNotification(DEFAULT_CODE); + } +} + +export default UnauthorizedError; diff --git a/backend/src/shared/errors/validation.ts b/backend/src/shared/errors/validation.ts new file mode 100644 index 0000000..d67b8f1 --- /dev/null +++ b/backend/src/shared/errors/validation.ts @@ -0,0 +1,24 @@ +import AppError from '@/shared/errors/app-error'; +import { + getNotification, + isNotification, +} from '@/shared/notifications/helpers'; + +const DEFAULT_CODE = 'errors.validation.message'; + +class ValidationError extends AppError { + constructor(messageCode?: string) { + super(400, ValidationError.resolveMessage(messageCode), { + code: messageCode ?? DEFAULT_CODE, + }); + } + + private static resolveMessage(messageCode?: string): string { + if (messageCode && isNotification(messageCode)) { + return getNotification(messageCode); + } + return getNotification(DEFAULT_CODE); + } +} + +export default ValidationError; diff --git a/backend/src/shared/jwt.ts b/backend/src/shared/jwt.ts new file mode 100644 index 0000000..9caa269 --- /dev/null +++ b/backend/src/shared/jwt.ts @@ -0,0 +1,10 @@ +import jwt from 'jsonwebtoken'; +import type { SignOptions } from 'jsonwebtoken'; +import config from '@/shared/config'; +import { JWT_EXPIRES_IN } from '@/shared/constants/auth'; + +/** Signs a short-lived access JWT with the application secret. */ +export function jwtSign(data: object): string { + const options: SignOptions = { expiresIn: JWT_EXPIRES_IN }; + return jwt.sign(data, config.secret_key, options); +} diff --git a/backend/src/shared/logger.ts b/backend/src/shared/logger.ts new file mode 100644 index 0000000..37272f4 --- /dev/null +++ b/backend/src/shared/logger.ts @@ -0,0 +1,49 @@ +/** + * Minimal centralized logger. A thin wrapper over `console` so all server-side + * logging goes through one place (timestamp + level + optional structured + * context) without pulling in a heavyweight logging dependency. Swap the + * implementation here if a transport (file/JSON/external) is ever needed. + */ +type LogContext = Record; + +function emit( + level: 'error' | 'warn' | 'info', + message: string, + context?: LogContext, +): void { + const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${message}`; + const payload = context && Object.keys(context).length > 0 ? [context] : []; + + if (level === 'error') { + console.error(line, ...payload); + } else if (level === 'warn') { + console.warn(line, ...payload); + } else { + console.log(line, ...payload); + } +} + +/** Serializes an unknown thrown value into a stable, loggable shape. */ +function describeError(error: unknown): LogContext { + if (error instanceof Error) { + return { name: error.name, message: error.message, stack: error.stack }; + } + return { error }; +} + +const logger = { + error(message: string, error?: unknown, context?: LogContext): void { + emit('error', message, { + ...(error !== undefined ? describeError(error) : {}), + ...context, + }); + }, + warn(message: string, context?: LogContext): void { + emit('warn', message, context); + }, + info(message: string, context?: LogContext): void { + emit('info', message, context); + }, +}; + +export default logger; diff --git a/backend/src/shared/notifications/helpers.ts b/backend/src/shared/notifications/helpers.ts new file mode 100644 index 0000000..9e37849 --- /dev/null +++ b/backend/src/shared/notifications/helpers.ts @@ -0,0 +1,34 @@ +import errors from '@/shared/notifications/list'; + +function getByPath(object: unknown, path: string): unknown { + return String(path) + .split('.') + .reduce( + (acc, key) => + acc && typeof acc === 'object' + ? (acc as Record)[key] + : undefined, + object, + ); +} + +function format(message: string, args: unknown[]): string { + return message.replace(/{(\d+)}/g, (match, number: string) => { + const idx = Number(number); + return typeof args[idx] !== 'undefined' ? String(args[idx]) : match; + }); +} + +export function isNotification(key: string): boolean { + return Boolean(getByPath(errors, key)); +} + +export function getNotification(key: string, ...args: unknown[]): string { + const message = getByPath(errors, key); + + if (typeof message !== 'string') { + return key; + } + + return format(message, args); +} diff --git a/backend/src/services/notifications/list.js b/backend/src/shared/notifications/list.ts similarity index 80% rename from backend/src/services/notifications/list.js rename to backend/src/shared/notifications/list.ts index 5b84eae..ea5fc3b 100644 --- a/backend/src/services/notifications/list.js +++ b/backend/src/shared/notifications/list.ts @@ -13,38 +13,35 @@ const errors = { emailAlreadyInUse: 'Email is already in use', invalidEmail: 'Please provide a valid email', passwordReset: { - invalidToken: - 'Password reset link is invalid or has expired', + invalidToken: 'Password reset link is invalid or has expired', error: `Email not recognized`, }, passwordUpdate: { - samePassword: `You can't use the same password. Please create new password` + samePassword: `You can't use the same password. Please create new password`, }, userNotVerified: `Sorry, your email has not been verified yet`, emailAddressVerificationEmail: { - invalidToken: - 'Email verification link is invalid or has expired', + invalidToken: 'Email verification link is invalid or has expired', error: `Email not recognized`, }, }, iam: { errors: { - userAlreadyExists: - 'User with this email already exists', + userAlreadyExists: 'User with this email already exists', userNotFound: 'User not found', disablingHimself: `You can't disable yourself`, revokingOwnPermission: `You can't revoke your own owner permission`, deletingHimself: `You can't delete yourself`, emailRequired: 'Email is required', + fileNameRequired: 'File name and public URL are required', }, }, importer: { errors: { invalidFileEmpty: 'The file is empty', - invalidFileExcel: - 'Only excel (.xlsx) files are allowed', + invalidFileExcel: 'Only excel (.xlsx) files are allowed', invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.', importHashRequired: 'Import hash is required', @@ -57,18 +54,27 @@ const errors = { forbidden: { message: 'Forbidden', }, + unauthorized: { + message: 'Authentication required', + }, + notFound: { + message: 'The requested resource was not found', + }, validation: { message: 'An error occurred', }, + internal: { + message: 'An unexpected error occurred', + }, searchQueryRequired: { - message: 'Search query is required', + message: 'Search query is required', }, }, emails: { invitation: { subject: `You've been invited to {0}`, - body: ` + body: `

Hello,

You've been invited to {0} set password for your {1} account.

{2}

@@ -101,4 +107,4 @@ const errors = { }, }; -module.exports = errors; +export default errors; diff --git a/backend/src/shared/object.ts b/backend/src/shared/object.ts new file mode 100644 index 0000000..a5ec577 --- /dev/null +++ b/backend/src/shared/object.ts @@ -0,0 +1,4 @@ +/** Type guard for a non-null, non-array plain object. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..24be948 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,12 @@ +import type { CurrentUser } from '@/db/api/types'; + +declare global { + namespace Express { + interface Request { + /** Authenticated user attached by the passport JWT strategy. */ + currentUser?: CurrentUser; + } + } +} + +export {}; diff --git a/backend/src/types/vendor.d.ts b/backend/src/types/vendor.d.ts new file mode 100644 index 0000000..eaebcfa --- /dev/null +++ b/backend/src/types/vendor.d.ts @@ -0,0 +1,65 @@ +// Ambient type declarations for packages that ship without their own types +// and have no @types package. Minimal, scoped to our usage. + +declare module 'passport-google-oauth2' { + import { Strategy as PassportStrategy } from 'passport-strategy'; + + interface GoogleStrategyOptions { + clientID: string; + clientSecret: string; + callbackURL: string; + passReqToCallback?: boolean; + } + + interface GoogleProfile { + email?: string; + [key: string]: unknown; + } + + type GoogleVerifyCallback = (error: unknown, user?: unknown) => void; + + type GoogleVerifyFunction = ( + request: unknown, + accessToken: string, + refreshToken: string, + profile: GoogleProfile, + done: GoogleVerifyCallback, + ) => void; + + export class Strategy extends PassportStrategy { + constructor(options: GoogleStrategyOptions, verify: GoogleVerifyFunction); + } +} + +declare module 'passport-microsoft' { + import { Strategy as PassportStrategy } from 'passport-strategy'; + + interface MicrosoftStrategyOptions { + clientID: string; + clientSecret: string; + callbackURL: string; + passReqToCallback?: boolean; + } + + interface MicrosoftProfile { + _json: { mail?: string; userPrincipalName?: string }; + [key: string]: unknown; + } + + type MicrosoftVerifyCallback = (error: unknown, user?: unknown) => void; + + type MicrosoftVerifyFunction = ( + request: unknown, + accessToken: string, + refreshToken: string, + profile: MicrosoftProfile, + done: MicrosoftVerifyCallback, + ) => void; + + export class Strategy extends PassportStrategy { + constructor( + options: MicrosoftStrategyOptions, + verify: MicrosoftVerifyFunction, + ); + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..af8cdbb --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..f92e33f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "allowJs": true, + "checkJs": false, + + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "paths": { + "@/*": ["./src/*"] + }, + + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/backend/watcher.js b/backend/watcher.js deleted file mode 100644 index 9e5e5bc..0000000 --- a/backend/watcher.js +++ /dev/null @@ -1,49 +0,0 @@ -const chokidar = require('chokidar'); -const { exec } = require('child_process'); -const nodemon = require('nodemon'); - -const nodeEnv = process.env.NODE_ENV || 'dev_stage'; -const childEnv = { ...process.env, NODE_ENV: nodeEnv }; - -const migrationsWatcher = chokidar.watch('./src/db/migrations', { - persistent: true, - ignoreInitial: true -}); -migrationsWatcher.on('add', (filePath) => { - console.log(`[DEBUG] New migration file: ${filePath}`); - exec('npm run db:migrate', { env: childEnv }, (error, stdout, stderr) => { - console.log(stdout); - if (error) { - console.error(stderr); - } - }); -}); - -const seedersWatcher = chokidar.watch('./src/db/seeders', { - persistent: true, - ignoreInitial: true -}); -seedersWatcher.on('add', (filePath) => { - console.log(`[DEBUG] New seed file: ${filePath}`); - exec('npm run db:seed', { env: childEnv }, (error, stdout, stderr) => { - console.log(stdout); - if (error) { - console.error(stderr); - } - }); -}); - -nodemon({ - script: './src/index.js', - env: childEnv, - ignore: ['./src/db/migrations', './src/db/seeders'], - delay: '500' -}); - -nodemon.on('start', () => { - console.log('Nodemon started'); -}); - -nodemon.on('restart', (files) => { - console.log('Nodemon restarted due changes in:', files); -}); diff --git a/backend/watcher.ts b/backend/watcher.ts new file mode 100644 index 0000000..e824414 --- /dev/null +++ b/backend/watcher.ts @@ -0,0 +1,40 @@ +import { exec } from 'child_process'; +import chokidar from 'chokidar'; +import nodemon from 'nodemon'; + +const nodeEnv = process.env.NODE_ENV || 'dev_stage'; +const childEnv = { ...process.env, NODE_ENV: nodeEnv }; + +function runOnAdd(label: string, dir: string, script: string): void { + chokidar + .watch(dir, { persistent: true, ignoreInitial: true }) + .on('add', (filePath) => { + console.log(`[DEBUG] New ${label} file: ${filePath}`); + exec(script, { env: childEnv }, (error, stdout, stderr) => { + console.log(stdout); + if (error) { + console.error(stderr); + } + }); + }); +} + +runOnAdd('migration', './src/db/migrations', 'npm run db:migrate'); +runOnAdd('seed', './src/db/seeders', 'npm run db:seed'); + +nodemon({ + script: './src/index.ts', + exec: 'tsx', + ext: 'ts,js,json', + env: childEnv, + ignore: ['./src/db/migrations', './src/db/seeders'], + delay: '500', +}); + +nodemon.on('start', () => { + console.log('Nodemon started'); +}); + +nodemon.on('restart', (files) => { + console.log('Nodemon restarted due to changes in:', files); +}); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7e20604..701eef0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,58 +1,52 @@ - - -version: "3.9" +# Local full stack: PostgreSQL + the app built from the root Dockerfile. +# The compiled backend serves both the API and the built SPA on one port (8080). +# NODE_ENV=development keeps it usable over plain HTTP (no forced secure cookies). +# Run from this folder: `docker compose up --build`, then open http://localhost:8080 services: - web: - image: frontend - build: ../frontend - stdin_open: true # docker run -i - tty: true # docker run -t - ports: - - "3000:3000" - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" db: - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - image: postgres + image: postgres:16-alpine + environment: + POSTGRES_DB: app_local + POSTGRES_USER: app_local + POSTGRES_PASSWORD: app_local volumes: - ./data/db:/var/lib/postgresql/data - environment: - - POSTGRES_HOST_AUTH_METHOD=trust - - POSTGRES_DB=db_school_chain_manager ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app_local -d app_local"] + interval: 5s + timeout: 5s + retries: 10 logging: driver: json-file options: max-size: "10m" max-file: "3" - backend: - image: backend - volumes: - - ./wait-for-it.sh:/usr/src/app/wait-for-it.sh - - ./start-backend.sh:/usr/src/app/start-backend.sh - build: ../backend + app: + build: + context: .. + dockerfile: Dockerfile environment: - - DB_HOST=db + NODE_ENV: development + PORT: "8080" + SECRET_KEY: local_dev_secret_change_me + DB_HOST: db + DB_PORT: "5432" + DB_NAME: app_local + DB_USER: app_local + DB_PASS: app_local + SEED_ADMIN_EMAIL: admin@example.com + SEED_ADMIN_PASSWORD: localAdmin123! + SEED_USER_PASSWORD: localUser123! ports: - - "8080:8080" - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" + - "8080:8080" depends_on: - - "db" - - - - command: ["bash", "./wait-for-it.sh", "db:5432", "--timeout=0", "--strict", "--", "bash", "./start-backend.sh"] - + db: + condition: service_healthy + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md new file mode 100644 index 0000000..5f6fd84 --- /dev/null +++ b/docs/deployment-docker.md @@ -0,0 +1,99 @@ +# Развёртывание через Docker (на хосте) + +Альтернативный способ запуска для локального стека и портируемого деплоя. +На боевой VM Flatlogic **Docker не используется** — там PM2 + локальный PostgreSQL ++ Cloudflare tunnel (см. [`deployment-vm.md`](./deployment-vm.md)). + +Проект состоит из двух приложений: + +- `frontend/` — Vite + React + TypeScript (SPA). Сборка → `frontend/dist/`. +- `backend/` — Express + Sequelize на TypeScript/ESM. Сборка → `backend/dist/`. + +## Файлы + +В корне репозитория: + +| Файл | Назначение | Порт | +|---|---|---| +| `Dockerfile` | прод single-image: компилированный бэк отдаёт API **и** SPA (из `public`) | 8080 | +| `Dockerfile.dev` | staging-реплика VM: nginx + фронт `vite preview` (3001) + бэк (3000), `dev_stage` | 8080 | +| `docker/docker-compose.yml` | локальный стек: PostgreSQL + app (из `Dockerfile`), `NODE_ENV=development` | 8080 | + +Все стейджи на `node:24-alpine`, `npm ci`. Нативный `bcrypt` собирается тулчейном +(`python3 make g++`) в builder-стейдже, в рантайм копируется готовый `node_modules` +(без перекомпиляции). `rolldown` (бандлер Vite) имеет musl-биндинги, поэтому alpine +подходит. + +## 1. Быстрый старт — docker compose (рекомендуется) + +Поднимает PostgreSQL + приложение одной командой. Бэкенд в `development` отдаёт API +и SPA на одном порту (логин работает по http). + +```bash +cd docker +docker compose up --build +# открыть http://localhost:8080 +``` + +Параметры (env заданы в `docker-compose.yml`, меняйте под себя): + +- `SECRET_KEY=local_dev_secret_change_me` +- `DB_*` указывают на сервис `db` (Postgres 16, БД/пользователь `app_local`) +- `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `SEED_USER_PASSWORD` + +Остановить и удалить (вместе с данными БД): + +```bash +docker compose down -v +``` + +## 2. Прод single-image (`Dockerfile`) + +Один контейнер: компилированный бэкенд на `NODE_ENV=production` слушает 8080 и +отдаёт и `/api`, и собранный SPA (фронт кладётся в `public`). + +```bash +docker build -t schoolchain:prod . + +docker run --rm -p 8080:8080 \ + -e NODE_ENV=production \ + -e PORT=8080 \ + -e SECRET_KEY=<секрет> \ + -e ALLOWED_ORIGINS=https://<ваш-домен> \ + -e DB_HOST= -e DB_PORT=5432 -e DB_NAME= -e DB_USER= -e DB_PASS= \ + -e SEED_ADMIN_EMAIL= -e SEED_ADMIN_PASSWORD= -e SEED_USER_PASSWORD= \ + schoolchain:prod +``` + +> В `NODE_ENV=production` обязательны `SECRET_KEY` и `ALLOWED_ORIGINS`, а cookie +> идут с флагом `Secure` (нужен HTTPS-фронт перед контейнером). Команда запуска +> (`npm run start:production`) сама прогоняет миграции и сидеры перед стартом — +> БД должна быть доступна. + +## 3. Staging-реплика VM (`Dockerfile.dev`) + +Повторяет схему VM в одном образе: nginx (8080) → фронт `vite preview` (3001) + бэк +(3000), `NODE_ENV=dev_stage`, source maps. Запускать за HTTPS (в `dev_stage` +cookie — `Secure`). + +```bash +docker build -t schoolchain:staging -f Dockerfile.dev . +docker run --rm -p 8080:8080 \ + -e SECRET_KEY=<секрет> \ + -e DB_HOST= -e DB_PORT=5432 -e DB_NAME= -e DB_USER= -e DB_PASS= \ + -e SEED_ADMIN_EMAIL= -e SEED_ADMIN_PASSWORD= -e SEED_USER_PASSWORD= \ + schoolchain:staging +``` + +## 4. `.dockerignore` + +Исключает `node_modules`, `dist`, `public`, `**/.env` (чтобы не запекать dev-секреты +в образ — окружение задаётся при запуске), `.git`, логи. + +## 5. NODE_ENV — что выбрать + +| `NODE_ENV` | Где | Особенности | +|---|---|---| +| `development` | локальный `docker compose` | http-логин работает; нет требований к `ALLOWED_ORIGINS`; cookie без `Secure` | +| `dev_stage` | `Dockerfile.dev` (staging) | прод-подобный, но мягкий; нужен HTTPS (`Secure` cookie); рефлектит origin | +| `production` | `Dockerfile` (прод) | обязательны `SECRET_KEY` + `ALLOWED_ORIGINS`; `Secure` cookie; строгий CORS | diff --git a/docs/deployment-vm.md b/docs/deployment-vm.md new file mode 100644 index 0000000..d439c4c --- /dev/null +++ b/docs/deployment-vm.md @@ -0,0 +1,258 @@ +# Развёртывание на виртуальной машине (Flatlogic executor) + +Так работает прод/превью Flatlogic: без Docker — PM2 + локальный PostgreSQL + +Cloudflare tunnel. В конце — справочная **структура файлов VM** (executor, pm2, +проект). + +> Запуск **через Docker на хосте** (compose / single-image / staging) вынесен в +> отдельный документ: [`deployment-docker.md`](./deployment-docker.md). + +Проект состоит из двух приложений: + +- `frontend/` — Vite + React + TypeScript (SPA). Сборка → `frontend/dist/`. +- `backend/` — Express + Sequelize на TypeScript/ESM. Сборка → `backend/dist/`. + +Проект живёт в `~/executor/workspace`, процессы — под PM2, БД — локальный +PostgreSQL, наружу — Cloudflare tunnel (`cloudflared`). Docker здесь не участвует. + +## 1.1. Топология + +``` + Браузер + │ https://.dev.flatlogic.app + ▼ + cloudflared (tunnel) + │ + ▼ + nginx :8080 + ├── / → frontend :3001 (Vite) + ├── /api → backend :3000 (Express) + └── /api-docs → backend :3000 + │ + ▼ + PostgreSQL :5432 (локальный) +``` + +- В `NODE_ENV=dev_stage` бэкенд слушает **3000** (`config.serverPort`), фронт — **3001**. +- Браузер открывает домен туннеля; фронт зовёт API относительным путём `/api` + (см. `frontend/src/shared/constants/api.ts`), nginx проксирует его на бэкенд — + тот же origin, поэтому CORS/CSRF не мешают. + +## 1.2. Окружение (env) + +**Инжектит платформа** в pm2-окружение процесса бэкенда (значения — секреты, в +репозиторий не коммитятся): + +| Переменная | Назначение | +|---|---| +| `NODE_ENV=dev_stage` | режим (прод-подобный, но без жёстких проверок прода) | +| `SECRET_KEY` | подпись JWT (обязательна — иначе бэкенд не стартует) | +| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | подключение к локальному Postgres (`DB_PASS` = UUID проекта) | +| `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET` | OAuth (опционально) | +| `SMTP_*`, `EMAIL_*`, `MAIL_*` | почта (опционально) | +| `CF_TUNNEL_*` | Cloudflare tunnel (для `cloudflared`, не для приложения) | + +**Из закоммиченного `backend/.env`** (не секреты уровня прода): + +- `PORT`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `SEED_USER_PASSWORD`. +- `backend/src/config/load-env.ts` находит `backend/.env` одинаково и для `tsx`, и + для компилированного `dist`. + +> `ALLOWED_ORIGINS` платформа НЕ задаёт. В `dev_stage` это допустимо: бэкенд не +> падает и рефлектит origin запроса (`config.auth.allowAllOrigins`). Жёсткая +> проверка `ALLOWED_ORIGINS` действует только при строгом `NODE_ENV=production`. + +## 1.3. PM2-процессы + +`pm2 status` на VM показывает (имена закреплены при провижининге): + +| Процесс | Команда (cwd) | Порт | +|---|---|---| +| `frontend-dev` | `npm run dev -- --hostname 0.0.0.0 --port 3001` (`workspace/frontend`) | 3001 | +| `backend-dev` | `NODE_ENV=dev_stage npm run start` (`workspace/backend`) | 3000 | +| `fl-executor` | `executor.js` — агент Flatlogic (cable, git, AI-команды) | — | +| `fl-telemetry` | `telemetry-daemon.js` | — | + +> ⚠️ `--hostname` — это флаг Next.js; Vite-CLI его отвергает (`Unknown option`). +> Поэтому `dev`/`start` фронта запускаются через обёртку `frontend/scripts/serve.mjs`, +> которая транслирует `--hostname`→`--host` и стартует Vite. Без неё фронт на VM +> не поднимется. + +## 1.4. Деплой после `git pull` + +PM2-команды зависимости **не ставят**. После подтягивания нового кода: + +```bash +cd ~/executor/workspace/backend && npm ci +cd ~/executor/workspace/frontend && npm ci +``` + +Схема БД создаётся инициальной миграцией. На уже существующей БД `npm run start` +сам прогонит `db:migrate` (идемпотентно, `CREATE TABLE IF NOT EXISTS`) + `db:seed`. +Для гарантированно чистого состояния (рекомендуется после крупной миграции — +**сотрёт данные**): + +```bash +cd ~/executor/workspace/backend && npm run db:reset # drop all tables → migrate → seed +``` + +Перезапуск процессов (или это делает executor после pull): + +```bash +pm2 restart backend-dev frontend-dev --update-env +``` + +### Что делают команды запуска + +- `backend-dev` → `npm run start` = `db:migrate` (инициальная миграция → схема) + + `db:seed` + `watch` (сервер через `tsx` + nodemon, порт 3000). +- `frontend-dev` → `npm run dev` (через `serve.mjs`) — Vite dev-сервер на 3001, + `allowedHosts: true` пускает домен туннеля. + +## 1.5. nginx + +Если nginx — системный сервис, его конфиг должен соответствовать `nginx.conf` из +репозитория (`/` → 3001, `/api` и `/api-docs` → 3000): + +```bash +sudo cp ~/executor/workspace/nginx.conf /etc/nginx/nginx.conf +sudo nginx -t && sudo nginx -s reload +``` + +## 1.6. Проверка + +```bash +curl -s -o /dev/null -w "front %{http_code}\n" http://127.0.0.1:3001/ +curl -s -o /dev/null -w "api %{http_code}\n" http://127.0.0.1:3000/api-docs/ +pm2 status +pm2 logs backend-dev --lines 50 +``` + +## 1.7. Прод-режим на VM (компилированные сборки) + +«Прод-режим» = **компилированные сборки при сохранённом `NODE_ENV=dev_stage`** +(минификация фронта, `node dist` бэка, source maps). Строгий `NODE_ENV=production` +использовать нельзя — он требует `ALLOWED_ORIGINS`, которого платформа не задаёт, +и бэкенд упадёт на старте. + +Для перевода в прод нужно сменить pm2-команды (на стороне executor): + +```bash +# Frontend +cd ~/executor/workspace/frontend && npm ci && npm run build +pm2 delete frontend-dev 2>/dev/null || true +FRONT_PORT=3001 pm2 start npm --name frontend --update-env -- run start + +# Backend (NODE_ENV=dev_stage сохраняем) +cd ~/executor/workspace/backend && npm ci && npm run build +pm2 delete backend-dev 2>/dev/null || true +NODE_ENV=dev_stage pm2 start npm --name backend --update-env -- run start:production + +pm2 save +``` + +- `frontend npm run start` = `vite preview` на `FRONT_PORT` (3001), отдаёт `dist/`. +- `backend npm run start:production` = `db:migrate:prod` + `db:seed:prod` + + `node --enable-source-maps dist/index.js` (порт 3000). + +Чтобы executor сам пересобирал прод при каждом обновлении кода — добавить +`npm ci && npm run build` в его шаг рестарта сервисов (см. `vcs.js`, ~строка 412, +массив `const services = ['backend-dev', 'frontend-dev']`). + +## 1.8. Траблшутинг + +| Симптом | Причина / решение | +|---|---| +| Фронт не стартует, `Unknown option --hostname` | старый код без `serve.mjs`; обнови workspace (`git pull` + `npm ci`) | +| Бэкенд падает: `ALLOWED_ORIGINS must be configured` | запущен с `NODE_ENV=production`; на VM должно быть `dev_stage` | +| Бэкенд падает: `SECRET_KEY` required | платформа не прокинула `SECRET_KEY` в pm2-env | +| `tsx: not found` / `vite: not found` | не выполнен `npm ci` после pull | +| Сид падает: `Seeding requires SEED_*` | нет `backend/.env` (или переменных в нём) | +| 502 на домене | бэк/фронт не слушают 3000/3001, либо nginx-роутинг не совпадает | + +--- + +# Часть 2. Структура файлов на VM (справочно) + +Снимок боевой VM (`pool-saas-*`). Полезно для понимания, где что лежит. + +## 2.1. Домашняя директория `~` + +``` +~/ +├── executor/ # агент Flatlogic + сам проект (см. ниже) +├── .pm2/ # PM2: процессы, логи, pids +│ ├── logs/ # *-out.log, *-error.log по процессам +│ ├── pids/ +│ └── dump.pm2 # сохранённый список процессов (pm2 save) +├── .bun/ .yarn/ .npm/ .cache/ # тулчейны/кэши +├── .codex/ .gemini/ .config/ # конфиги AI-CLI +├── .ssh/ .pki/ +├── recipes.md +└── google-gemini-cli-0.17.1.tgz +``` + +## 2.2. `~/executor/` — агент Flatlogic + +``` +~/executor/ +├── .env # конфиг агента и проекта (PROJECT_UUID, PROJECT_ID, +│ # CABLE_URL, DB_NAME=app_, SUBDOMAIN, FRONT_PORT, ...) +├── executor.js # главный процесс агента (pm2: fl-executor): +│ # WebSocket-cable к flatlogic.com, приём команд, +│ # запуск AI-раннеров (gemini/codex), fs/git-операции +├── gemini.js / gemini-proc.js / opencode.js / opencode-proc.js # AI-раннеры +├── vcs.js и vcs/vcs.js # git: init/pull/push/commit, Gitea-зеркало, +│ # рестарт сервисов (массив ['backend-dev','frontend-dev']) +├── vm-tools.js # инструменты VM (команды от платформы) +├── activity-tracker.js # трекинг активности раннера +├── telemetry-daemon.js / telemetry-server.js / telemetry-file-watcher.js # телеметрия (pm2: fl-telemetry) +├── sentry.js # Sentry +├── config.js # WORKSPACE_ROOT и пр. +├── index.php # статичная страница-прелоадер («Analyzing your requirements…») +├── setup_postgres_project.sh # создание роли/БД проекта в локальном Postgres +├── setup_mariadb_project.sh # то же для MariaDB +├── setup_workspace_permissions.sh +├── cleanup_vm.sh +├── AGENTS.md / README.md # инструкции для AI-агента (описывают шаблон проекта) +├── otel-local.yaml / schema.json / proto/ +├── node_modules/ package.json package-lock.json +├── workspace/ # ◀── САМ ПРОЕКТ (git-репозиторий = этот репозиторий) +├── workspace_baseline.tar.gz # базовый снимок workspace +├── workspace_codegen/ # рабочая область кодогенерации +└── templates/ # шаблоны для новых проектов + ├── app-templates/ + └── frontend-tailwind-backend-nodejs/ # Next.js+Node шаблон (НЕ наш стек) +``` + +> Файлы `executor/` (агент) и `templates/` к запуску **нашего** проекта отношения +> не имеют — их менять не нужно. `index.php` — только прелоадер на время генерации. + +## 2.3. `~/executor/workspace/` — проект + +Это и есть данный git-репозиторий (`40227-vm`): + +``` +workspace/ +├── frontend/ # Vite + React + TS → собирается в frontend/dist, сервится на :3001 +├── backend/ # Express + Sequelize TS/ESM → backend/dist, сервится на :3000 +│ ├── .env # PORT, SEED_* (закоммичен) +│ └── src/db/migrations/ # инициальная миграция (схема) +├── nginx.conf # роутинг / → 3001, /api → 3000 (для системного nginx) +├── Dockerfile / Dockerfile.dev / docker/ # альтернативный Docker-путь +├── 502.html +└── docs/ # в т.ч. этот файл +``` + +## 2.4. `~/executor/.env` — ключи (значения секретны/индивидуальны) + +``` +PROJECT_UUID, PROJECT_ID, CABLE_URL, GEMINI_MODEL, TELEMETRY_*, OTEL_*, +MAIL_*, SMTP_*, SUBDOMAIN, BASE_DOMAIN, FULL_DOMAIN, HOST_FQDN, +DB_NAME=app_, DB_USER=app_, DB_HOST=127.0.0.1, DB_PORT=5432, +FRONT_PORT=3001, SENTRY_DSN +``` + +Секреты приложения (`SECRET_KEY`, `DB_PASS`, OAuth/SMTP, токены git, `CF_TUNNEL_*`) +платформа кладёт прямо в **pm2-окружение** процессов `backend-dev`/`fl-executor`, а +не в `backend/.env`. diff --git a/docs/development-path.md b/docs/development-path.md index 4acc274..afae43e 100644 --- a/docs/development-path.md +++ b/docs/development-path.md @@ -96,7 +96,7 @@ Use `organizations` as the tenant boundary. Required work: -- Ensure every tenant-owned query is scoped by `currentUser.organizationsId` or equivalent organization relation. +- Ensure every tenant-owned query is scoped by `currentUser.organizationId` or equivalent organization relation. - Keep global access only for true platform/admin roles. - Verify create/update/delete operations cannot cross tenant boundaries. - Add tests for tenant isolation on high-risk entities: users, staff, campuses, students, attendance, messages, documents, and new customer UI modules. diff --git a/docs/full-integration-refactor-plan.md b/docs/full-integration-refactor-plan.md index 25e77d7..ae6aa1d 100644 --- a/docs/full-integration-refactor-plan.md +++ b/docs/full-integration-refactor-plan.md @@ -47,11 +47,13 @@ Frontend current baseline: Backend current baseline: -- Product module files have been syntax-checked with `node -c`. -- `npm audit --audit-level=low` reports 0 vulnerabilities. -- `npm outdated` reports only `json2csv@6.0.0-alpha.2` above the installed stable `5.0.7`; prerelease packages are not part of the stable dependency baseline. -- `npm run lint` still fails on existing generated/template lint debt. -- `npm run db:migrate` still needs a local database verification run with the configured environment. +- Runtime code is 100% TypeScript + native ESM; the JS->TS / CJS->ESM migration is complete. +- `npm run typecheck` (`tsc --noEmit`) passes. +- `npm run lint` (`eslint .`) passes with no broad ignores. +- `npm test` (`node --test` via `tsx`) passes: 2 files, 15 tests (error-handler + import-boundaries). +- `npm run verify` (typecheck + lint + test) is the combined gate and is green. +- `npm run build` (`tsc` + `tsc-alias -f` + email-template asset copy) produces a runnable `dist/`. +- Migrations/seeders run via Umzug (`npm run db:migrate`, `npm run db:seed`); a run against the configured local database is still pending (Workstream 1). ## Active Workstreams @@ -79,28 +81,7 @@ Acceptance criteria: - `GET /api/public/content-catalog/:contentType` works for the required seeded content catalog types. - Any remaining backend runtime blocker is captured as a specific follow-up with file/error references. -### 2. Backend Lint Debt Cleanup - -Status: open. - -Problem: - -Backend ESLint 10 is configured, but `npm run lint` fails on existing generated/template lint debt. - -Required work: - -1. Fix unused imports, unused variables, useless assignments, and generated-template lint failures. -2. Do not add broad ignores to hide debt. -3. Do not disable rules inline unless a documented, reviewed exception is unavoidable. -4. Keep generated-code behavior unchanged while cleaning lint. - -Acceptance criteria: - -- `npm run lint` passes from `backend/`. -- No broad lint-disable blocks are added. -- No generated route/service behavior changes unless required to remove a real defect. - -### 3. Tenant Boundary Audit And Tests +### 2. Tenant Boundary Audit And Tests Status: open and high priority. @@ -111,7 +92,7 @@ Generated backend code has partial tenant scoping. Multi-tenant correctness cann Required work: 1. Audit all tenant-owned generated and product routes for organization scoping. -2. Resolve inconsistent `organizationsId` versus `organizationId` usage where it affects tenant-owned data. +2. ~~Resolve inconsistent `organizationsId` versus `organizationId` usage~~ — done: unified on `organizationId` for all models (renamed the `users.organizationsId` column via migration `20260609000000-rename-users-organizationsid-to-organizationid`, updated db/api scoping, the auth DTO, and the frontend `CurrentUser` type). Verify the tenant-scoping `where` now correctly targets each model's `organizationId`. 3. Ensure create/update/delete paths cannot accept another tenant's organization or campus. 4. Ensure list/count/autocomplete endpoints are tenant-scoped. 5. Add backend tests proving cross-tenant records are not visible or mutable. @@ -122,7 +103,7 @@ Acceptance criteria: - A non-global user cannot read or mutate another tenant's data. - Campus-scoped users cannot mutate another campus unless their backend permission explicitly allows it. -### 4. Role Model Decision +### 3. Role Model Decision Status: open. @@ -148,7 +129,7 @@ Acceptance criteria: - Backend tests prove role access for product modules. - No frontend role grants capability that backend does not enforce. -### 5. Product Onboarding Contract +### 4. Product Onboarding Contract Status: blocked by customer decision. @@ -188,7 +169,7 @@ Acceptance criteria: - Backend owns all creation, assignment, and permission checks. - Frontend only exposes flows backed by typed backend contracts. -### 6. Refresh Token Maintenance +### 5. Refresh Token Maintenance Status: open. @@ -209,7 +190,7 @@ Acceptance criteria: - Cleanup failures are visible and not silent. - Auth behavior remains unchanged for valid sessions. -### 7. API Documentation Hardening +### 6. API Documentation Hardening Status: open. @@ -232,7 +213,7 @@ Acceptance criteria: - Cookie auth behavior is explicit. - Frontend API contract tests remain aligned with docs. -### 8. Policy And Safety Acknowledgment Persistence +### 7. Policy And Safety Acknowledgment Persistence Status: open pending product contract. @@ -260,7 +241,7 @@ Acceptance criteria: - Acknowledgment status is tenant-scoped. - Unauthorized roles cannot view individual acknowledgment records. -### 9. Attendance Source Contracts +### 8. Attendance Source Contracts Status: partially open. @@ -289,7 +270,7 @@ Acceptance criteria: - UI values can be traced to backend records or server-side derivation. - No frontend-only attendance source remains in persisted workflows. -### 10. Generated Audio Provider Contract +### 9. Generated Audio Provider Contract Status: open pending provider decision. @@ -316,7 +297,7 @@ Acceptance criteria: - No provider secret reaches the browser. - Provider failures remain visible. -### 11. File Upload And Download Permissions +### 10. File Upload And Download Permissions Status: open. @@ -336,7 +317,7 @@ Acceptance criteria: - Unauthorized users cannot access another tenant's files. - Upload/download behavior is documented and tested before new UI is added. -### 12. Public Backend Route Audit +### 11. Public Backend Route Audit Status: open. @@ -356,7 +337,7 @@ Acceptance criteria: - Every unauthenticated backend route is documented. - No tenant-owned data is exposed through accidental public routes. -### 13. Backend-Seeded Authenticated E2E +### 12. Backend-Seeded Authenticated E2E Status: blocked by onboarding/profile fixtures. @@ -386,7 +367,7 @@ Acceptance criteria: - Tests do not require production secrets. - Tests are documented and repeatable. -### 14. Accessibility Test Coverage +### 13. Accessibility Test Coverage Status: open. @@ -404,7 +385,7 @@ Acceptance criteria: - Accessibility tests run in a documented command. - Critical violations block completion of the refactor. -### 15. `ref-frontend/` Removal +### 14. `ref-frontend/` Removal Status: open. @@ -420,9 +401,9 @@ Acceptance criteria: - Only `frontend/` and `backend/` remain as active application code. - No docs describe `ref-frontend/` as needed for normal development. -### 16. OAuth Provider Strategy Modernization +### 15. OAuth Provider Strategy Modernization -Status: open. Deferred — run after the backend TypeScript/ESM migration (see `backend/docs/typescript-esm-migration-plan.md`, decision 0.7). +Status: open. Deferred until after the backend TypeScript/ESM migration (now complete), to keep auth-flow changes separate from the language/module migration. Problem: @@ -443,22 +424,151 @@ Acceptance criteria: - Cookie/JWT auth behavior is unchanged for existing sessions. - This change is isolated from the language/module migration (separate PR/task). +### 16. Permission-Based Frontend Authorization + +Status: open. + +Problem: + +The backend already authorizes every request by **permission**, not by role. `backend/src/middlewares/check-permissions.ts` exposes `checkCrudPermissions(entity)`, which derives a permission name `${METHOD}_${ENTITY}` (e.g. `READ_CAMPUSES`, `CREATE_USERS`, `DELETE_ROLES` via `METHOD_MAP`) and grants access when any of the following holds, in order: (1) self-access — `currentUser.id === req.params.id`/`req.body.id`; (2) the user has a direct `custom_permissions` entry with that name; (3) the user's `app_role` (or the `Public` role for unauthenticated/no-role requests) has that permission via `role.getPermissions()`. An admin can therefore create roles and attach permissions (`Roles ↔ Permissions` many-to-many through `RolesDBApi.setPermissions`), plus assign per-user `custom_permissions`. + +The frontend, however, does **not** gate on permissions. Module/page access is currently decided by `productRole` (`teacher | para | office | director | superintendent`) in `frontend/src/business/app-shell/selectors.ts` (`canAccessModule(modules, moduleId, userRole)`), and per the architecture contract frontend route hiding is **UX-only**. The user DTO already carries a `permissions: string[]` array (built by `getPermissionNames` in `services/auth.ts`), but the frontend ignores it. This makes the frontend authorization model inconsistent with the backend: a role with customized permissions is correctly enforced by the backend but not reflected in what the UI shows or allows. + +Goal: make the frontend gate UI affordances (routes/menu items/buttons/request triggers) by **permission** — using the same `${METHOD}_${ENTITY}` permission names the backend checks — while keeping the backend as the sole source of truth (frontend gating stays UX-only; it must never be the only enforcement). + +Required work: + +1. **Expose permissions reliably in the auth contract.** Confirm `GET /api/auth/me` and the sign-in response include the resolved `permissions: string[]` (effective permissions = role permissions ∪ `custom_permissions`). Add `custom_permissions` to the resolution if not already merged. Document the exact field in `backend/docs/auth-profile.md`. +2. **Define a shared permission vocabulary.** Add a typed catalog of permission names on the frontend (`frontend/src/shared/auth/permissions.ts`) mirroring backend naming `${METHOD}_${ENTITY}` (READ/CREATE/UPDATE/DELETE × entity). Keep it in `shared/constants`-style UI config; do not import backend code. +3. **Add a permission selector/hook.** Implement `hasPermission(user, permissionName)` and `hasAnyPermission/hasAllPermissions` in `frontend/src/business/auth/` (pure functions over `CurrentUser.permissions`), plus a `usePermissions()` hook for components. Include the self-access nuance only where relevant (the backend allows self-access regardless of permission). +4. **Gate routes by permission.** Replace/augment role-based `canAccessModule` with permission-based checks. Each route/module declares the permission(s) it requires; the router redirects/hides when the user lacks them. Keep `productRole` only where a true role concept is still needed (e.g. dashboards), not for resource access. +5. **Gate UI affordances.** Hide or disable create/edit/delete buttons and other action triggers based on the matching permission (e.g. hide "Add campus" without `CREATE_CAMPUSES`). Avoid firing requests the backend will reject with 403. +6. **Handle 403 consistently.** Ensure the API layer surfaces backend `forbidden` responses to a single handler (toast + no crash), so permission drift between UI and backend degrades gracefully. +7. **Admin UI for roles/permissions.** Verify the roles management screens let an admin create a role, attach/detach permissions, and assign `custom_permissions` to a user — backed by the existing `roles`/`permissions`/`users` endpoints. +8. **Tests.** Unit-test `hasPermission`/selectors with permission fixtures; add route-guard tests proving a user without `READ_X` cannot open module X; add a backend-seeded e2e proving UI affordances match backend enforcement for at least one CRUD entity. +9. **Docs.** Update `frontend/docs/frontend-architecture.md` and `backend/docs/auth-profile.md` to describe the permission-based frontend model and the `${METHOD}_${ENTITY}` contract. + +Acceptance criteria: + +- Frontend route/menu/affordance visibility is driven by the user's effective permissions, using the same names the backend enforces. +- The backend remains the sole authority: removing a frontend check never grants real access (backend still returns 403). +- An admin can create a role, assign permissions, and the change is reflected in both backend enforcement and frontend UI for affected users. +- `productRole`-based gating is removed for resource access (kept only for genuine role-specific UI), with no remaining role↔permission inconsistency. +- Frontend `typecheck`, `lint`, `test` pass; a seeded e2e proves UI/permission alignment for at least one entity. + +### 17. API Surface Coverage And Dead-Endpoint Decision + +Status: open (analysis complete; decision pending). + +Problem: + +The backend exposes the full Flatlogic-generated CRUD surface for all 39 models plus +template auth/file/search routes, but the product frontend only consumes a small set of +custom feature endpoints. The unused surface is dead code and attack surface; it must be +either pruned or intentionally wired. + +Method (how this was established): + +- Enumerated every backend route from `backend/src/routes/*` plus its mount prefix in `backend/src/index.ts`. +- Enumerated every frontend HTTP call. All frontend HTTP goes through `frontend/src/shared/api/*` over `httpClient` (`fetch`); there are no stray `fetch`/`axios`/`XMLHttpRequest` calls elsewhere, so the frontend call list is exhaustive. + +Findings: + +- **Frontend is fully wired:** every `shared/api` method targets a real, mounted backend endpoint. There are **0 orphan/broken frontend calls**. +- **Backend is only partially consumed:** of ~278 endpoints, the frontend uses **~35 (~13%)**; **~243 (~87%) have no frontend consumer.** +- The mismatch is one-directional: the frontend calls nothing extra; the backend carries a large unused layer. + +Consumed endpoints (the only wired surface — 35): + +- `auth` (4 of 16): `POST /api/auth/signin/local`, `GET /api/auth/me`, `POST /api/auth/refresh`, `POST /api/auth/signout`. +- `campuses` (1): `GET /api/public/campuses` (the authenticated `/api/campuses` CRUD is NOT used). +- `content-catalog` (6): `GET /api/public/content-catalog/:contentType`, `GET /api/content-catalog`, `GET /api/content-catalog/:contentType`, `POST /api/content-catalog`, `PUT /api/content-catalog/:contentType`, `DELETE /api/content-catalog/:contentType`. +- `campus_attendance` (4): `GET /configs`, `PUT /configs/:campusKey`, `GET /summaries`, `PUT /summaries/:campusKey/:date`. +- `communications` (4): `GET /parent-messages`, `POST /parent-messages`, `GET /events`, `POST /events`. +- `frame_entries` (3): `GET /`, `POST /`, `PUT /:id`. +- `personality_quiz_results` (3): `GET /me`, `PUT /me`, `GET /distribution`. +- `safety_quiz_results` (2): `GET /`, `POST /`. +- `staff_attendance` (2): `GET /records`, `GET /summary`. +- `user_progress` (3): `GET /`, `POST /`, `DELETE /by-item`. +- `walkthrough_checkins` (3): `GET /`, `POST /`, `DELETE /:id`. + +Unused backend endpoints (no frontend consumer): + +1. **Generic CRUD template — 25 route groups × 9 endpoints = 225, none called:** + `academic_years`, `assessments`, `assessment_results`, `attendance_records`, + `attendance_sessions`, `campuses` (the `/api/campuses` CRUD), `classes`, + `class_enrollments`, `class_subjects`, `fee_plans`, `grades`, `guardians`, + `invoices`, `message_recipients`, `messages`, `organizations`, `payments`, + `permissions`, `roles`, `staff`, `students`, `subjects`, `timetable_periods`, + `timetables`, `users`. + Each exposes the identical shape: `POST /`, `POST /bulk-import`, `PUT /:id`, + `DELETE /:id`, `POST /deleteByIds`, `GET /`, `GET /count`, `GET /autocomplete`, + `GET /:id`. +2. **`auth` extras — 12, not called:** `POST /api/auth/signup`, `PUT /api/auth/profile`, + `PUT /api/auth/password-reset`, `PUT /api/auth/password-update`, + `POST /api/auth/send-password-reset-email`, + `POST /api/auth/send-email-address-verification-email`, + `PUT /api/auth/verify-email`, `GET /api/auth/email-configured`, + `GET /api/auth/signin/google` (+ `/callback`), `GET /api/auth/signin/microsoft` (+ `/callback`). +3. **`file` — 2, not called:** `GET /api/file/download`, `POST /api/file/upload/:table/:field` + (the frontend never uploads; `DocumentMutationDto` has no `file`). +4. **`search` — 1, not called:** `GET /api/search`. + +**Decision (owner, recorded):** the generic CRUD layer is **WIRE — kept, not +pruned.** These ~24 groups are not dead code; they will be used and integrated +with the frontend later (likely modeled on `ref-frontend`). No generic CRUD group +is to be removed. The `auth`/`file`/`search` extras (items 2–4 below) remain +individually decision-gated. + +Required work: + +1. Generic CRUD groups: **WIRE** (decided above) — build the frontend that uses + them (e.g. real `students`/`staff`/`guardians`/`invoices` management) and bring + each group up to the target backend architecture. Do **not** prune them. +2. `auth` extras: keep `signup`/password-reset/verify-email only if onboarding/recovery is + in scope (see Workstream 4); otherwise prune. OAuth callbacks tie to Workstream 15. +3. `file`: keep only if document/avatar upload is on the roadmap (ties to Workstream 10); + otherwise prune. +4. `search`: prune unless a search UI is planned. +5. After any prune, re-run backend `typecheck`/`lint`, and regenerate + `database-schema.md` only if models change. + +Cross-references: Workstream 10 (file upload), 11 (public route audit), 15 (OAuth), 16 +(permission-based authorization). + +Acceptance criteria: + +- Every backend endpoint is classified as **used**, **prune**, or **wire (planned)**, with no “unclassified” remainder. +- Pruned routes are removed cleanly (route + service + db/api + permission wiring) with `typecheck`/`lint` green and no dangling imports. +- The frontend remains fully wired (0 orphan calls) after changes. +- This document reflects the final surface. + +Performance hardening applied (owner chose **wire**, not prune, for the generic CRUD layer — it will back near-term management UIs modeled on `ref-frontend`): + +- `findBy` in all 24 generic-CRUD `db/api/*.ts` now loads its associations via a single `Promise.all` instead of sequential awaited getters (detail-endpoint latency drops from sum to max of the association queries). `users.findBy` was done earlier. +- List pagination now has shared defaults via `@/shared/constants/pagination` (`resolvePagination`, default page size 10 to match the `ref-frontend` grids, capped at 100). Applied to every generic-CRUD `findAll` and to the feature lists (`user_progress`, `safety_quiz_results`, `walkthrough_checkins`, `frame_entries`, `content_catalog`, `communications` parent-messages/events, `campus_attendance` configs), which were reverted from `findAll` back to `findAndCountAll` so `count` is the true total for the pager. `staff_attendance /records` and `campus_attendance /summaries` keep their pre-existing per-endpoint limits. +- Fixed a latent CRUD tenant-scoping bug: routes and `db/api` create/update read `currentUser.organization?.id` (singular), but `findBy` only ever populates `organizations` (plural) + the `organizationId` scalar, so that read was always `undefined` (non-global creates silently set no organization). All 22 `db/api` + 24 route reads now use `currentUser.organizationId`; the dead 3-field fallback term was dropped from the 4 feature services; and the non-existent `organization?: { id }` field was removed from the `CurrentUser` type so the mistake cannot recur. +- The redundant existence-check `findBy` in the 22 CRUD `update` services was removed; they now rely on `DbApi.update` returning `null` (avoids loading every association just to validate existence). `remove` already had no pre-check. +- The per-request `UsersDBApi.findBy` (passport JWT strategy → `req.currentUser`, read by every guarded route) was collapsed from `findOne` + 4 parallel association getters + a `getPermissions()` into a **single** eager-loaded query (`findOne` + `include` of `app_role`+`permissions`, `staff_user`, `custom_permissions`, `organizations`). Its returned `app_role` now carries `permissions`, and `middlewares/check-permissions.ts` was reordered to read that eager-loaded `permissions` array before falling back to `getPermissions()` — removing the extra per-request permissions query. Same returned shape/fields as before (`AuthenticatedUser`); ~6–7 queries per request → 1. The cached `Public` role still works (its record carries `permissions`). +- `AuthService.currentUserProfile` (the `GET /me`, signin and refresh responses) now uses a dedicated `UsersDBApi.findProfileById` — a single eager-loaded query (`findByPk` + scoped `include`/`attributes`) returning only the columns and relations the profile DTO reads — instead of the heavy generic `findBy` (1 `findOne` + parallel association getters + `getPermissions`) plus a separate `getCampus`. Required idiomatic `NonAttribute` association declarations on the `Users`/`Roles`/`Staff` models so the include is type-safe without casts. The per-request passport `findBy` that populates `req.currentUser` is unchanged (it is the auth gate read by every guard); `/me` still performs that auth fetch plus this one lean profile query. + ## Strict Implementation Sequence Use this order unless the user explicitly reprioritizes: 1. Backend migration and seed verification. -2. Backend lint debt cleanup. -3. Tenant boundary audit and tests. -4. Role model decision and enforcement tests. -5. Public route audit. -6. API documentation hardening. -7. Product onboarding after customer decision. -8. Authenticated backend-seeded e2e after onboarding/profile fixtures exist. -9. Remaining optional product contracts: acknowledgments, attendance imports, generated audio, file upload/download UI. -10. Accessibility test coverage. -11. Remove `ref-frontend/`. -12. OAuth provider strategy modernization (after backend TS/ESM migration). +2. Tenant boundary audit and tests. +3. Role model decision and enforcement tests. +4. Public route audit. +5. API documentation hardening. +6. Product onboarding after customer decision. +7. Authenticated backend-seeded e2e after onboarding/profile fixtures exist. +8. Remaining optional product contracts: acknowledgments, attendance imports, generated audio, file upload/download UI. +9. Accessibility test coverage. +10. Remove `ref-frontend/`. +11. OAuth provider strategy modernization. +12. Permission-based frontend authorization (align frontend gating with backend permission checks). +13. API surface coverage decision: classify every endpoint and prune or wire the unused backend layer (Workstream 17). ## Definition Of Done diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index e67846f..77ae4d1 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["dist", "scripts"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], diff --git a/frontend/package.json b/frontend/package.json index d6b8b20..aad6dbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,11 +3,15 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=24" + }, "scripts": { - "dev": "vite", + "dev": "node scripts/serve.mjs dev", "typecheck": "tsc -b --pretty false", "build": "npm run typecheck && vite build", "build:dev": "vite build --mode development", + "start": "node scripts/serve.mjs preview", "lint": "eslint .", "test": "vitest run", "test:e2e": "env -u NO_COLOR -u FORCE_COLOR playwright test", diff --git a/frontend/scripts/serve.mjs b/frontend/scripts/serve.mjs new file mode 100644 index 0000000..0a4724c --- /dev/null +++ b/frontend/scripts/serve.mjs @@ -0,0 +1,35 @@ +// Starts the Vite dev or preview server, tolerating the CLI flags the platform +// appends to `npm run dev` / `npm run start` (notably the legacy Next.js +// `--hostname`, which the Vite CLI rejects). Usage: node scripts/serve.mjs +import { createServer, preview } from 'vite'; + +const [mode = 'dev', ...rest] = process.argv.slice(2); + +const server = {}; +for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === '--host' || arg === '--hostname') { + const next = rest[i + 1]; + if (next && !next.startsWith('--')) { + server.host = next; + i++; + } else { + server.host = true; + } + } else if (arg === '--port') { + server.port = Number(rest[++i]); + } +} + +if (server.port === undefined && process.env.FRONT_PORT) { + server.port = Number(process.env.FRONT_PORT); +} + +if (mode === 'preview') { + const s = await preview({ preview: server }); + s.printUrls(); +} else { + const s = await createServer({ server }); + await s.listen(); + s.printUrls(); +} diff --git a/frontend/src/business/auth/mappers.test.ts b/frontend/src/business/auth/mappers.test.ts index d62fc71..bdb153f 100644 --- a/frontend/src/business/auth/mappers.test.ts +++ b/frontend/src/business/auth/mappers.test.ts @@ -14,7 +14,7 @@ function createUser(overrides: Partial = {}): CurrentUser { lastName: 'Lee', app_role: null, organizations: null, - organizationsId: 'org-1', + organizationId: 'org-1', campus: { id: 'campus-1', name: 'North Campus', code: 'north' }, campusId: 'campus-1', productRole: 'teacher', diff --git a/frontend/src/shared/api/documents.ts b/frontend/src/shared/api/documents.ts index ba2bf02..bc9efae 100644 --- a/frontend/src/shared/api/documents.ts +++ b/frontend/src/shared/api/documents.ts @@ -15,15 +15,20 @@ export function listPolicyDocuments(): Promise> { ); } -export function createDocument(request: DocumentMutationDto): Promise { - return apiRequest(DOCUMENTS_PATH, { +export function createDocument( + request: DocumentMutationDto, +): Promise { + return apiRequest(DOCUMENTS_PATH, { method: 'POST', body: { data: request }, }); } -export function updateDocument(id: string, request: DocumentMutationDto): Promise { - return apiRequest(`${DOCUMENTS_PATH}/${id}`, { +export function updateDocument( + id: string, + request: DocumentMutationDto, +): Promise { + return apiRequest(`${DOCUMENTS_PATH}/${id}`, { method: 'PUT', body: { id, diff --git a/frontend/src/shared/constants/api.ts b/frontend/src/shared/constants/api.ts index d1ee10c..6b2a9d2 100644 --- a/frontend/src/shared/constants/api.ts +++ b/frontend/src/shared/constants/api.ts @@ -1,5 +1,8 @@ +// Defaults to a same-origin relative path so the production build works behind +// any domain (the server serves the SPA and the API on the same origin). For +// local dev with separate ports, set VITE_BACKEND_API_URL in frontend/.env. export const API_BASE_URL = - import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + import.meta.env.VITE_BACKEND_API_URL || '/api'; export const API_HEADERS = { contentType: 'Content-Type', diff --git a/frontend/src/shared/types/auth.ts b/frontend/src/shared/types/auth.ts index 58cae6f..9fb1ff3 100644 --- a/frontend/src/shared/types/auth.ts +++ b/frontend/src/shared/types/auth.ts @@ -35,7 +35,7 @@ export interface CurrentUser { readonly lastName?: string | null; readonly app_role?: BackendRole | null; readonly organizations?: BackendOrganization | null; - readonly organizationsId?: string | null; + readonly organizationId?: string | null; readonly campus?: BackendCampus | null; readonly campusId?: string | null; readonly productRole: UserRole; diff --git a/frontend/src/shared/types/campusAttendance.ts b/frontend/src/shared/types/campusAttendance.ts index 8838f05..b746031 100644 --- a/frontend/src/shared/types/campusAttendance.ts +++ b/frontend/src/shared/types/campusAttendance.ts @@ -9,7 +9,7 @@ export interface CampusAttendanceConfigDto { readonly updated_by_label: string | null; readonly organizationId: string; readonly campusId: string | null; - readonly createdById: string | null; + readonly createdById: string; readonly updatedById: string | null; readonly createdAt: string; readonly updatedAt: string; @@ -32,7 +32,7 @@ export interface CampusAttendanceSummaryDto { readonly notes: string | null; readonly organizationId: string; readonly campusId: string | null; - readonly createdById: string | null; + readonly createdById: string; readonly updatedById: string | null; readonly createdAt: string; readonly updatedAt: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a4ee7dc..b57fdd2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,19 @@ export default defineConfig(({ mode }) => ({ server: { host: "::", port: 3000, + // Served behind a proxy/tunnel on an arbitrary domain; allow any Host. + allowedHosts: true, + // Proxy API requests to backend - enables same-origin cookies in dev mode + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + preview: { + host: true, + allowedHosts: true, }, plugins: [ react() @@ -17,6 +30,7 @@ export default defineConfig(({ mode }) => ({ }, }, build: { + sourcemap: true, rollupOptions: { output: { manualChunks(id) { diff --git a/nginx.conf b/nginx.conf index 926fcdd..b7fced9 100644 --- a/nginx.conf +++ b/nginx.conf @@ -6,10 +6,6 @@ events { http { client_max_body_size 10M; - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } server { listen 8080; @@ -20,15 +16,7 @@ http { internal; } - location /api/logError { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://127.0.0.1:3001/api/logError; - } - - + # Backend (in dev_stage the server listens on port 3000). location /api-docs/ { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -49,48 +37,16 @@ http { proxy_pass http://127.0.0.1:3000/api/; } + # Frontend (Vite) on port 3001 — serves the SPA (dev or `vite preview`). location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://127.0.0.1:3001/; - } - - location /app-shell/ { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://127.0.0.1:4000/; - } - - location /locales/ { - root /app/frontend/public; - expires 1h; - add_header Cache-Control "public, must-revalidate"; - try_files $uri =404; - } - - location ~ /_next/webpack-hmr { - proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location ~ ^/_next/ { - proxy_pass http://127.0.0.1:3001; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection "upgrade"; + proxy_pass http://127.0.0.1:3001/; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 740473e..7d8d051 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "app", "version": "0.0.1", "scripts": { - "build:production": "cd ./frontend && npm ci && npm run build && cd ../backend && npm ci", - "start:production": "cd ./backend && NODE_ENV=production npm run start" + "build:production": "cd ./frontend && npm ci && npm run build && cd ../backend && npm ci && npm run build", + "start:production": "cd ./backend && NODE_ENV=production npm run start:production" } }