Refactor: migrate frontend to Vite/React, add product backend modules

Frontend:
- Replace Next.js with Vite + React + TypeScript
- Add new component architecture (app-shell, sidebar, dashboard modules)
- Implement product modules: FRAME, safety protocols, walkthrough checkin,
  campus/staff attendance, personality quiz, sign language, classroom timer
- Add shadcn/ui component library with Tailwind CSS
- Remove legacy generated components, stores, and pages

Backend:
- Add product migrations: frame_entries, user_progress, safety_quiz_results,
  walkthrough_checkins, communication_events, personality_quiz_results,
  campus_attendance_config/summaries, staff_attendance_records, content_catalog
- Add corresponding models, services, and routes
- Implement cookie-based auth with refresh token rotation
- Add content catalog seeder with product content
- Migrate to ESLint flat config
- Switch from yarn to npm

Infrastructure:
- Update .gitignore for new tooling
- Add project documentation (CLAUDE.md, docs/)
- Remove deprecated config files and yarn.lock

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dmitri 2026-06-09 15:18:23 +02:00
parent 28777dbab5
commit d4a5378adf
1143 changed files with 57306 additions and 15805 deletions

9
.gitignore vendored
View File

@ -1,3 +1,12 @@
node_modules/
*/node_modules/
*/build/
.claude
.DS_Store
*.tsbuildinfo
.env
.env.*
*.env
*.env.*
!.env.example
!*.env.example

30
CLAUDE.md Normal file
View File

@ -0,0 +1,30 @@
# Repository Guidelines
**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.
## Working Principles
1. **Check docs first**: Read relevant documents files before starting tasks (links provided below)
2. **Minimal changes**: Update only necessary files, prefer simple robust solutions
3. **Use SKILLS**: For best results in specific areas, use the appropriate **SKILLS** in `.codex/skills` (56 skills available)
4. **Agents orchestration**: Use `.codex/skills/ai-agent-orchestrator/SKILL.md` skill to improve your efficiency.
5. **Use MCP servers**: Available via `mcp__<server>__<tool>`: GitHub (`github`), Chrome DevTools (`chrome-devtools`)
6. **TypeScript strict mode**: Avoid `any` types, **NEVER** disable linter or TypeScript, **NEVER** use types casting
7. **Concise comments**: Explain "why" not "what", code should be self-documenting
8. **No over-engineering**: Build for a small SaaS used by owner-operators. Choose the simplest robust solution that is fast to implement, easy to understand, and easy to support. Avoid enterprise-style architecture and unnecessary complexity: no extra fallbacks, premature abstractions, microservices, Kubernetes, complex orchestration, heavy caching, or distributed-system patterns unless the task explicitly requires them. User-facing copy must use plain language and avoid accounting jargon.
9. **Native errors for external services**: Pass through errors from Gemini/OpenAI as-is
10. **Centralized exceptions only**: Always use centralized exceptions instead of inlined logs or exceptions
11. **Use tools and agents**: Use MCP servers, web search, and agents when needed
12. **Avoid hardcoded constants**: Add to `backend/src/constants/` or `frontend/src/shared/constants/`
13. **Avoid silent failures**: Provide proper observability for all failure modes
14. **Documentation matters**: After **EACH** task update appropriate documentation files AND for **EACH** new module or feature create documentation file in appropriate directory: `backend/docs/`, `frontend/docs/` or common `docs/`
15. **Aliases for imports**: For **ALL** imports use aliases `@` instead of absolute or relative paths
16. **Tests matters**: After **EACH** task update appropriate unit and e2e tests AND for **EACH** new functionality create new tests (unit or e2e depending on the features complexity and importance)
## 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`
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.

50
backend/.env.example Normal file
View File

@ -0,0 +1,50 @@
NODE_ENV=development
PORT=8080
# Required secret for signing JWTs. Use a long random value locally and in production.
SECRET_KEY=replace_with_a_long_random_secret
# Local PostgreSQL connection. Sequelize development config still defaults to localhost values when these are empty.
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASS=
DB_NAME=db_school_chain_manager
# Frontend and Swagger public URLs used for redirects and generated API docs.
UI_HOST=http://localhost
UI_PORT=3000
SWAGGER_HOST=http://localhost
SWAGGER_PORT=8080
# Browser auth cookie and credentialed CORS. These are non-secret deployment values.
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
AUTH_ACCESS_COOKIE_NAME=school_chain_session
AUTH_REFRESH_COOKIE_NAME=school_chain_refresh
AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_MAX_AGE_MS=900000
AUTH_REFRESH_TOKEN_MAX_AGE_MS=1209600000
AUTH_COOKIE_DOMAIN=
# Seed-only local credentials. Do not use production passwords here.
SEED_ADMIN_EMAIL=admin@example.com
SEED_ADMIN_PASSWORD=replace_with_local_seed_password
SEED_USER_PASSWORD=replace_with_local_seed_password
# Optional external integrations.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
EMAIL_FROM=School Chain Manager <app@example.com>
EMAIL_HOST=
EMAIL_PORT=587
EMAIL_USER=
EMAIL_PASS=
GCLOUD_BUCKET=
GCLOUD_HASH=
PEXELS_KEY=
PEXELS_QUERY=Lighthouse guiding ships at dawn
GPT_KEY=
FLATLOGIC_PROJECT_UUID=

View File

@ -1,4 +0,0 @@
# Ignore generated and runtime files
node_modules/
tmp/
logs/

View File

@ -1,15 +0,0 @@
module.exports = {
env: {
node: true,
es2021: true
},
extends: [
'eslint:recommended'
],
plugins: [
'import'
],
rules: {
'import/no-unresolved': 'error'
}
};

View File

@ -0,0 +1,51 @@
# Auth Profile Contract
## Purpose
`GET /api/auth/me` is the backend-owned current user profile contract for the product frontend.
The endpoint 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`.
## Response Shape
The endpoint returns:
- `id`
- `email`
- `firstName`
- `lastName`
- `phoneNumber`
- `organizationsId`
- `organizations`
- `app_role`
- `productRole`
- `staffProfile`
- `campus`
- `campusId`
- `permissions`
`productRole` is derived server-side from generated backend roles first, then staff type, then the default teacher role.
## Constants
Role names and mappings live in `backend/src/constants/roles.js`.
Do not duplicate generated-role to product-role mapping in frontend code.
## Security Rules
- 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.
## Known Gaps
- 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.

View File

@ -0,0 +1,65 @@
# Campus Attendance Backend
## Purpose
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.
## API
All routes require JWT authentication.
- `GET /api/campus_attendance/configs`
- `GET /api/campus_attendance/configs?campusKey=<campusKey>`
- `PUT /api/campus_attendance/configs/:campusKey`
- `GET /api/campus_attendance/summaries`
- `GET /api/campus_attendance/summaries?campusKey=<campusKey>&startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>`
- `PUT /api/campus_attendance/summaries/:campusKey/:date`
## Access Rules
- 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.
## Data Contract
Config mutation fields:
- `attendance_link`
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`

View File

@ -0,0 +1,46 @@
# Campus Catalog
## Purpose
The database is the source of truth for campus records. Runtime frontend code must not ship campus rows as constants.
## Backend Contract
Public read-only campus catalog:
- `GET /api/public/campuses`
Response shape:
```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
}
```
Only active campuses are returned. The endpoint is intentionally read-only and does not replace the authenticated `/api/campuses` CRUD workflow.
Campus identity, names, codes, mascot labels, online flag, descriptions, and branding tokens are campus data and belong in the `campuses` table.
## Seed Data
Initial product campuses are seeded by:
- `backend/src/db/seeders/20260608100000-product-campuses.js`
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.

View File

@ -0,0 +1,49 @@
# Communications Backend
## Purpose
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`
## API
All routes require JWT authentication.
- `GET /api/communications/parent-messages`: returns parent messages created by the current user.
- `GET /api/communications/parent-messages?category=<category>`: filters the current user's parent messages by category.
- `POST /api/communications/parent-messages`: creates one sent parent message and recipient log.
- `GET /api/communications/events`: returns internal alert events visible to the current user's organization and campus scope.
- `GET /api/communications/events?type=<type>`: filters events by type.
- `POST /api/communications/events`: creates one internal alert event.
## Access Rules
- 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.
## Data Contract
Parent message create fields:
- `recipientName`
- `messageText`
- `category`
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.

View File

@ -0,0 +1,94 @@
# Content Catalog
## Purpose
`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`
## API
Public read endpoint:
- `GET /api/public/content-catalog/:contentType`
The response returns one active content payload for the requested `contentType`.
Authenticated management endpoints:
- `GET /api/content-catalog`
- `POST /api/content-catalog`
- `GET /api/content-catalog/:contentType`
- `PUT /api/content-catalog/:contentType`
- `DELETE /api/content-catalog/:contentType`
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
- 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.
## Classroom Strategies
`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.

View File

@ -0,0 +1,56 @@
# Cookie Auth
## Purpose
Browser authentication uses a backend-owned HttpOnly cookie. The product frontend does not store, read, or send auth tokens manually.
## Runtime Contract
- `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.
## Refresh Tokens
Auth uses access/refresh cookie rotation:
- 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
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.
## Configuration
Cookie and CORS values are configured through `backend/src/config.js` from non-secret environment values:
- `ALLOWED_ORIGINS`
- `AUTH_COOKIE_NAME`
- `AUTH_COOKIE_SAME_SITE`
- `AUTH_COOKIE_SECURE`
- `AUTH_COOKIE_MAX_AGE_MS`
- `AUTH_COOKIE_DOMAIN`
Access and refresh cookie names and lifetimes are part of this config contract.
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.
## 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.

View File

@ -0,0 +1,40 @@
# FRAME Entries Backend
## 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.
## 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.
## 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`
The frontend may hide editing controls, but backend role checks remain authoritative.
## Data Contract
Required request fields:
- `week_of`
- `posted_date`
- `formal`
- `recognition`
- `application`
- `management`
- `emotional`
- `author`
Tenant scope is assigned from the current authenticated user. `campusId` is optional and defaults to the current staff profile campus when available.

View File

@ -0,0 +1,44 @@
# Personality Quiz Results Backend
## 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.
The workflow uses a dedicated tenant-owned table:
- `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.
## API
All routes require JWT authentication.
- `GET /api/personality_quiz_results/me`: returns the current user's saved result or `null`.
- `PUT /api/personality_quiz_results/me`: creates or updates the current user's saved result.
- `GET /api/personality_quiz_results/distribution`: returns aggregate counts by personality type for authorized report roles.
- `GET /api/personality_quiz_results/distribution?campusId=<campusId>`: filters aggregate counts by campus when provided.
## 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.
## Data Contract
Result mutation fields:
- `personalityType`
- `quizAnswers`
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.
## Files
- `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`

View File

@ -0,0 +1,32 @@
# Safety Quiz Results Backend
## 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.
## API
All routes require JWT authentication.
- `GET /api/safety_quiz_results`: returns quiz results visible to the current user.
- `GET /api/safety_quiz_results?week_of=<week>`: filters visible results by week.
- `POST /api/safety_quiz_results`: saves one quiz result for the current user.
## 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.
## Data Contract
Required mutation fields:
- `quiz_id`
- `quiz_title`
- `week_of`
- `score`
- `total_questions`
- `answers`
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.

View File

@ -0,0 +1,56 @@
# Staff Attendance Backend
## Purpose
The staff attendance API provides staff-level attendance records and summary counts for the attendance snapshot and director dashboard.
This workflow is separate from:
- student-level generated attendance models: `attendance_sessions`, `attendance_records`
- campus daily aggregate summaries: `campus_attendance_summaries`
## 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
## API
All routes require JWT authentication.
- `GET /api/staff_attendance/records`
- `GET /api/staff_attendance/records?startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>&limit=<number>`
- `GET /api/staff_attendance/summary`
- `GET /api/staff_attendance/summary?startDate=<YYYY-MM-DD>&endDate=<YYYY-MM-DD>&limit=<number>`
## 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`.
## Remaining Related Work
The module currently exposes read/report endpoints only. Add write or import endpoints when the staff attendance source system is defined.
## Files
- `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`

View File

@ -0,0 +1,427 @@
# План миграции бэкэнда на TypeScript + ESM
Статус: черновик плана (реализация ещё не начата). Дата: 2026-06-09.
Этот документ описывает полную, поэтапную миграцию `backend/` с CommonJS/JavaScript на
TypeScript со строгим режимом и нативные ESM-модули (`import`/`export`). План составлен
так, чтобы приложение оставалось рабочим на каждом шаге и соответствовало правилам
`CLAUDE.md` (без `any`, без отключения линтера/TS, без приведения типов, импорты через
алиас `@`, документация и тесты на каждый модуль, минимальные изменения, без
оверинжиниринга).
---
## 0. Принятые решения (2026-06-09)
Зафиксированы по итогам обсуждения и проверочных spike:
1. **Дата/время:** `moment`**`dayjs`** (минимальный diff, близкий API).
2. **sequelize-cli в ESM (spike проведён):** подтверждено — CLI загружает `.cjs`-конфиг и `.sequelizerc` в пакете с `"type": "module"`. Но т.к. выбраны TS-миграции (см. п.4), sequelize-cli из миграционного флоу убирается.
3. **Инструменты:** dev — раннер **`tsx`** (исполняет `.ts`+ESM напрямую, это инструмент, не React-разметка), прод — сборка **`tsc` + `tsc-alias`**. Бандлер не вводим.
4. **Миграции — на TypeScript.** Используем **Umzug 3 + `tsx`** (spike подтвердил выполнение `.ts`-миграций end-to-end). Хранилище — `SequelizeStorage` поверх существующей таблицы `SequelizeMeta`, поэтому история уже применённых миграций сохраняется. Лучшие практики — см. раздел 11.
5. **Версия Node:** **24 (Active LTS)**. Удовлетворяет самым строгим ограничениям зависимостей (`vitest`, `eslint` требуют `>=24`). Node 26 — ещё «Current», не LTS, в прод не берём.
6. **Тесты:** минимальный смоук-набор на этапе миграции (+ unit на чистые модули).
7. **OAuth-стратегии passport:** обновление — **отдельной задачей** после миграции; задача внесена в `docs/full-integration-refactor-plan.md`.
ORM остаётся **Sequelize 6.37** (решение зафиксировано отдельно: Prisma — слишком большой рефакторинг, v7 — alpha).
---
## 1. Текущее состояние (факты, на которых строится план)
Замеры по `backend/src` (без `node_modules`):
- Всего файлов `.js`: **206**, суммарно ~**55 651** строк.
- Распределение по слоям:
- `db/` — 90 файлов (модели 39, db/api 28, миграции 15, сидеры 5), ~33 400 строк — самый объёмный слой.
- `routes/` — 45 файлов, ~12 700 строк.
- `services/` — 48 файлов, ~7 600 строк.
- `constants/` — 13 файлов, `middlewares/` — 3, `auth/` — 2, `config/` — 1 + `config.js`, `ai/` — 1.
- Модульная система: **100% CommonJS**. `require(` используется в 173 файлах, `module.exports` — в 201; ESM-импортов (`import …`) — **0**.
- Экспорт-паттерны: `module.exports = class` — 74 файла (services, db/api, middlewares, helpers); `module.exports = function(sequelize, DataTypes)` — 38 (фабрики моделей Sequelize).
- ORM: **Sequelize 6.37**, миграции/сидеры через **sequelize-cli 6.6.5** (CommonJS-инструмент, читает `.sequelizerc``src/db/db.config.js`).
- Express **5.2**, аутентификация на **passport** (JWT + Google + Microsoft), документация через **swagger-jsdoc** (парсит `./src/routes/*.js`).
- Dev-запуск: `watcher.js` (chokidar + nodemon), он же триггерит `db:migrate` / `db:seed` при появлении новых файлов.
- ESLint flat-config с `sourceType: 'commonjs'` и правилом `import-x/no-unresolved`.
- Тестов в бэкэнде сейчас **нет** (0 файлов `*.test.js` / `*.spec.js`); проверка — только `node -c` (синтаксис) согласно `docs/full-integration-refactor-plan.md`.
- Алиас `@` в бэкэнде пока **не используется** (хотя `CLAUDE.md` его требует); все импорты относительные.
- Окружения: `engines.node >= 18`, Dockerfile на `node:20.15.1-alpine`, локально доступен Node 22.
Важный ориентир: **фронтенд уже на TypeScript + ESM + Vite** (strict, `paths: { "@/*": ["./src/*"] }`,
`vitest`, `playwright`). Бэкэнд логично привести к тем же конвенциям (strict TS, алиас `@`, vitest).
### ESM-блокеры, выявленные в коде
1. **Динамическая загрузка моделей** в `db/models/index.js`: `fs.readdirSync(__dirname)` + `require(path.join(...))` в цикле. В нативном ESM `require` недоступен; нужен либо явный список `import`, либо динамический `import()`.
2. **`__dirname` / `__filename`** в 7 файлах: `config/load-env.js`, `index.js`, `ai/LocalAIApi.js`, `db/models/index.js`, `services/email/list/{passwordReset,addressVerification,invitation}.js`. В ESM их нет — замена на `fileURLToPath(import.meta.url)`.
3. **sequelize-cli** загружает `.sequelizerc`, `db.config.js`, миграции и сидеры как CommonJS через `require`. При `"type": "module"` файлы `.js` станут ESM и CLI сломается.
4. **swagger-jsdoc** указывает на `./src/routes/*.js` — после сборки путь к исходникам/выходу изменится.
5. **`watcher.js` + nodemon** заточены под запуск `.js`-файла напрямую.
6. В ESM (NodeNext) **относительные импорты требуют явного расширения** (`./foo.js`), а алиасы tsconfig **не резолвятся Node в рантайме** без дополнительного инструмента.
---
## 2. Целевое состояние
- Весь рантайм-код бэкэнда — `.ts` со строгим TypeScript (`strict: true`, `noImplicitAny`, `strictNullChecks`), без `any` и без приведения типов.
- Нативный ESM: `"type": "module"` в `package.json`, `import`/`export` во всём рантайм-коде.
- Импорты через алиас `@` (как на фронтенде), работающие и в dev, и в прод-сборке.
- Сборка `tsc``dist/`, запуск прод из `dist/`; dev — через `tsx watch` (TS + ESM + алиасы без отдельной сборки).
- Миграции и сидеры остаются на sequelize-cli и сохраняют CommonJS (расширение `.cjs`) — как исторические артефакты, переписывать их на TS нецелесообразно.
- Введён минимальный каркас типизированных тестов (`vitest`) и `typecheck` как гейт качества.
- Обновлены Dockerfile, скрипты `package.json`, ESLint и документация.
---
## 3. Ключевые проектные решения (с обоснованием)
### 3.1. Две фазы вместо «большого взрыва»
Задача совмещает две независимые трансформации: смену **языка** (JS → TS) и смену
**модульной системы** (CJS → ESM). Делать обе сразу на 206 файлах рискованно. Рекомендуется
разделить:
- **Фаза A — типизация (остаёмся на CommonJS).** TypeScript компилируется в `module: "CommonJS"`. Включаем `allowJs: true`, чтобы `.ts` и `.js` сосуществовали, и мигрируем файлы снизу вверх по дереву зависимостей. Приложение всё это время запускается как раньше. Риск минимальный.
- **Фаза B — перевод на ESM.** Когда весь код уже на TS, механически переводим модульную систему: `"type": "module"`, `module: "NodeNext"`, `require``import`, `module.exports``export`, `__dirname``import.meta.url`, переписываем динамический загрузчик моделей, чиним sequelize-cli и swagger-пути.
Такое разделение изолирует «языковые» ошибки от «модульных» и даёт чёткие точки отката.
> Альтернатива (не рекомендуется): одновременный переход на `tsx` + `"type": "module"` и
> правка всех импортов сразу. Быстрее по числу шагов, но даёт большой нестабильный
> diff и трудный откат. Противоречит принципу «минимальные изменения».
### 3.2. Инструмент сборки и запуска
- **Сборка:** `tsc` (официальный компилятор) в `dist/`. Просто, предсказуемо, без бандлера — соответствует «без оверинжиниринга».
- **Алиасы `@` в выходной сборке:** `tsc` не переписывает `@/*` в относительные пути. Добавляем `tsc-alias` как пост-шаг сборки. (Альтернатива — нативные `imports` в `package.json` с префиксом `#`, но `CLAUDE.md` требует именно `@`.)
- **Dev-запуск:** `tsx watch src/index.ts` — исполняет TS+ESM напрямую и резолвит `paths` из tsconfig, заменяя `nodemon`. Авто-миграции/сидинг из `watcher.js` выносим в отдельный `predev`-шаг или оставляем лёгкий watcher только для `db/migrations`+`db/seeders` (см. 4.7).
### 3.3. Миграции — переход на Umzug + TypeScript (решение принято)
sequelize-cli — CommonJS-инструмент и не поддерживает `.ts`-миграции. Поскольку выбраны
TS-миграции (решение 0.4), **отказываемся от sequelize-cli** в пользу **Umzug 3** — это та же
библиотека, что лежит под капотом sequelize-cli, поэтому переход совместим по хранилищу
истории.
Подход (детали и пример каркаса — в разделе 11):
- Собственный лёгкий раннер `db/migrate.ts` на Umzug, запускаемый через `tsx`.
- Хранилище истории — `SequelizeStorage` поверх существующей таблицы **`SequelizeMeta`**. Уже применённые миграции остаются записанными, повторно не выполняются.
- **Новые** миграции пишутся как `.ts` (ESM, типизированный `QueryInterface`).
- **Существующие 15 миграций** не переписываем: оставляем как есть в формате `.cjs` (исторические артефакты), Umzug-glob включает и `.cjs`, и `.ts`, `tsx` грузит оба. Это исключает риск нарушить уже применённую историю БД.
- Сидеры — вторым экземпляром Umzug (отдельная meta-таблица) либо оставляем минимальный текущий механизм; решается в разделе 11.
> Spike подтвердил: Umzug 3.8.3 + `tsx` выполняют `.ts`-миграции end-to-end. Отдельно
> подтверждено, что `.cjs`-артефакты корректно грузятся в ESM-пакете, что нужно для
> сосуществования старых `.cjs` и новых `.ts` миграций.
Модели переводим на TS/ESM (их импортирует рантайм). Команды миграций моделей не требуют.
### 3.4. Загрузчик моделей и типобезопасный объект `db`
Заменяем `fs.readdirSync + require` на **явные `import` всех 39 моделей** в `db/models/index.ts`
и сборку строго типизированного объекта `db`. Это убирает динамику (несовместимую с ESM-статикой),
даёт автодополнение и исключает `any`. Минус — вербозный index с 39 импортами, но это
разовый предсказуемый код. **Динамический `import()` здесь не используем** — только статические.
### 3.4.1. Политика импортов: статика по умолчанию
Решение: **избегаем динамических импортов (`import()`)**, кроме случаев, где это действительно
оправдано. На текущем коде таких случаев нет:
- Единственный реальный динамический `require` — загрузчик моделей (`db/models/index.js:25`) — переводится на статические импорты (3.4).
- Найденные `import("…")` — это JSDoc-аннотации типов, а не рантайм; в TS становятся обычными `import type`.
- `__dirname`/`__filename` в 7 файлах используются для `fs`-чтения файлов (`.env`, HTML-шаблоны писем), **не для импортов**. Замена на `path.dirname(fileURLToPath(import.meta.url))` сохраняет их статическими — это не динамический импорт.
Если в будущем потребуется ленивая загрузка (тяжёлый опциональный модуль, разрыв цикла
зависимостей), динамический `import()` допускается **точечно и с обоснованием в коде**, а не как
паттерн. Циклы зависимостей предпочтительно разрывать рефакторингом, а не `import()`.
### 3.5. Стратегия типизации моделей Sequelize
Модели сейчас — фабрики `sequelize.define('name', {...})`. Чтобы не переписывать 39 моделей в
class-based стиль (большой рискованный diff), рекомендуется **остаться на `define()`**, но
добавить типобезопасность:
- На каждую модель — интерфейс атрибутов (`XAttributes`) и creation-атрибутов (`XCreationAttributes`).
- Фабрика возвращает `ModelStatic<Model<XAttributes, XCreationAttributes>>`.
- `associate` типизируется через общий тип реестра `Db`.
Это самый объёмный по трудозатратам пункт миграции (39 моделей + связи), поэтому он вынесен в
отдельную подфазу и может идти параллельно остальному после готовности каркаса.
### 3.6. Типизация Express и слоёв
- `@types/express`, `Request`/`Response`/`NextFunction`, расширение `Request` полем `currentUser` через declaration merging (`src/types/express.d.ts`).
- `helpers.wrapAsync`, `commonErrorHandler`, middlewares — строго типизированные сигнатуры.
- db/api и services — публичные методы получают типы входных DTO и возвращаемых моделей.
---
## 4. Поэтапный план работ
### Фаза 0 — Подготовка инструментов (без изменения рантайм-поведения)
1. Установить dev-зависимости: `typescript`, `tsx`, `tsc-alias`, `@types/node`, и типы для библиотек без собственных деклараций: `@types/express`, `@types/cors`, `@types/passport`, `@types/passport-jwt`, `@types/jsonwebtoken`, `@types/nodemailer`, `@types/multer`, `@types/swagger-jsdoc`, `@types/swagger-ui-express`, `@types/bcrypt`, `@types/validator`. (axios, sequelize, pg, `dayjs`, `@json2csv/plainjs`, `umzug` поставляют типы сами; `@types/lodash` не нужен — `lodash` удаляется.) Добавить рантайм-зависимость `umzug` (для TS-миграций, см. раздел 11).
2. Создать `backend/tsconfig.json` (Фаза A — CommonJS):
- `strict: true`, `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch` (зеркало фронтенда).
- `target: ES2022`, `module: CommonJS`, `moduleResolution: Node`, `esModuleInterop: true`.
- `allowJs: true`, `checkJs: false` — для сосуществования `.js` и `.ts`.
- `outDir: dist`, `rootDir: src`, `baseUrl: src`, `paths: { "@/*": ["*"] }`.
3. Обновить ESLint: добавить `typescript-eslint`, парсер для `.ts`, сохранить `import-x/no-unresolved` с TS-резолвером. На Фазе A не ломать существующие `.js`-правила.
4. Добавить скрипт `typecheck: tsc --noEmit` и завести его как обязательный гейт.
Критерий готовности фазы: `npm run typecheck` проходит на смешанной кодовой базе, приложение запускается как раньше.
### Фаза 0.5 — Аудит и обновление зависимостей (совмещается с Фазой A)
Полный разбор — в разделе 10. Кратко по порядку:
1. **Удалить неиспользуемые** драйверы и пакеты: `mysql2`, `tedious`, `sqlite` (диалект везде `postgres`, 0 использований), `lodash` (один импорт `lodash/get`, заменяется на optional chaining).
2. **Заменить deprecated** `json2csv@5.0.7``@json2csv/plainjs@7` (26 файлов).
3. **Заменить устаревшую** `moment`**`dayjs`** (26 файлов).
4. **Консолидировать загрузку файлов**: убрать `formidable` (1 файл), оставить `multer`; убрать `body-parser``express.json()` (Express 5).
5. **Убрать `sequelize-cli`** (миграции переходят на Umzug + tsx, см. раздел 11). OAuth-стратегии — отдельной задачей (см. `full-integration-refactor-plan.md`); `sequelize-json-schema` пересмотреть при типизации `routes/openai.ts`.
6. Минорно поднять актуальные пакеты (`@google-cloud/storage` 7.19→7.21 и т.п.).
Эти изменения лучше делать вместе с типизацией соответствующих модулей, чтобы новые типы сразу ложились на новые API. Каждое — отдельным PR со смоук-проверкой.
### Фаза A — Перевод на TypeScript (язык), модульная система = CommonJS
Мигрируем снизу вверх по зависимостям, по одному связному блоку за раз; после каждого блока — `typecheck` + ручной/смоук-прогон.
1. `constants/` (13 файлов) — листовые, без зависимостей. Перевести в `.ts`, экспортировать типизированные константы (`as const`).
2. `config/load-env.ts`, `config.ts` — типизировать чтение env (хелперы `requiredEnv`/`readBooleanEnv`/`readNumberEnv`/`readListEnv` уже есть, добавить типы и тип `Config`).
3. `helpers.ts`, `db/utils.ts` — общие утилиты.
4. `db/models/*` — типизация моделей (см. 3.5) + типобезопасный `db/models/index.ts` (см. 3.4). Самый крупный блок; можно дробить по доменам.
5. `db/api/*` (28 файлов) — типизировать публичные статические методы, опираясь на типы моделей.
6. `services/*` (48 файлов), включая `services/notifications/errors/*`, `services/email/list/*`.
7. `auth/*` и `middlewares/*` — типизация passport-стратегий, cookies, проверки прав; declaration merging для `Request.currentUser`.
8. `ai/LocalAIApi.ts`.
9. `routes/*` (45 файлов) — типизированные роутеры; swagger-JSDoc-комментарии переносятся как есть (swagger-jsdoc парсит и `.ts`).
10. `index.ts` — точка входа.
11. Существующие миграции/сидеры/`db.config`/`.sequelizerc` — на Фазе A **не трогаем** (остаются `.js`, CommonJS-пакет их корректно исполняет). Перевод на Umzug — в Фазе B.
Критерий готовности: 0 `.js` в рантайм-коде (кроме исторических миграций/сидеров), `typecheck` зелёный, приложение работает.
### Фаза B — Перевод на ESM (модульная система)
1. Переключить tsconfig на ESM: `module: NodeNext`, `moduleResolution: NodeNext`. Отключить `allowJs` (язык уже весь TS).
2. Поставить `"type": "module"` в `package.json`.
3. Конвертировать синтаксис во всех `.ts`: `require``import`, `module.exports``export` / `export default`. (Можно ускорить кодмодом `cjstoesm` — но результат вычитывать вручную; правило: не доверять слепо.)
4. `__dirname`/`__filename``path.dirname(fileURLToPath(import.meta.url))` в 7 файлах.
5. Переписать `db/models/index.ts` на статические `import` всех моделей.
6. Поправить относительные импорты под NodeNext (явные расширения в выходе обеспечивает `tsc-alias`; алиас `@` — предпочтительный путь).
7. **Миграции на Umzug + tsx (раздел 11):** убрать `sequelize-cli` и `.sequelizerc`; перевести `db.config.js``db/config.ts`; добавить раннер `db/migrate.ts` (Umzug + `SequelizeStorage` на `SequelizeMeta`); существующие 15 миграций оставить как `.cjs`, новые писать на `.ts`. Проверить `migrate up` на чистой БД и на БД с уже применённой историей.
8. **swagger-jsdoc:** сделать путь `apis` env-зависимым — dev: `./src/routes/*.ts`, прод: `./dist/routes/*.js`.
9. **dev-запуск:** заменить `nodemon`/`watcher.js` на `tsx watch src/index.ts` (hot-reload + стриминг логов в реальном времени). Авто-применение миграций — `predev`-шаг (`tsx src/db/migrate.ts up`); если нужно сохранить применение «на лету» при добавлении файла, оставить лёгкий chokidar-вотчер на `db/migrations`/`db/seeders`. Паритет с текущим поведением проверяется в Фазе D, п.4.
10. `db/reset.js` (использует `execSync('sequelize ...')`) — переписать на вызов Umzug-раннера (`migrate down/up` или `sequelize.sync` для dev), перевести в `.ts`/ESM.
Критерий готовности: `npm run build` (tsc + tsc-alias) собирает `dist/`, `node dist/index.js` стартует, миграции/сидеры проходят, все маршруты отвечают.
### Фаза C — Скрипты, сборка, контейнеризация
1. `package.json` скрипты:
- `dev: tsx watch src/index.ts`
- `build: tsc && tsc-alias`
- `start: node dist/index.js` (прод; миграции — отдельной командой перед стартом)
- `db:migrate: tsx src/db/migrate.ts up`, `db:rollback: tsx src/db/migrate.ts down`, `db:seed: tsx src/db/seed.ts` (раздел 11)
- `typecheck`, `lint`, `test`.
- Указать `engines.node: ">=24"` (Active LTS, решение 0.5).
2. Обновить корневой `package.json` (`build:production`, `start:production`) под новый build-флоу бэкэнда.
3. **Копирование ассетов в `dist/`.** `tsc` собирает только `.ts`. Не-TS ассеты, которые читаются через `fs` по пути от `import.meta.url`, нужно копировать в `dist/` отдельным build-шагом: HTML-шаблоны писем `src/services/email/htmlTemplates/**` (используются в `services/email/list/*`). Добавить в `build` (например, `cpy`/`copyfiles`/`rsync`) и проверить, что пути от `import.meta.url` резолвятся в `dist/`.
4. Dockerfile: базовый образ **`node:24-alpine`** (текущая Active LTS); добавить шаг `npm run build`, в прод-образ копировать `dist/` (включая скопированные шаблоны) + `node_modules` (multi-stage build). Заменить `yarn install` на `npm ci` (консистентно с корневым `build:production`).
5. `.dockerignore`/`.gitignore`: добавить `dist/`.
### Фаза D — Тесты и верификация
1. Добавить `vitest` (консистентно с фронтендом) и базовый каркас тестов.
2. Покрыть unit-тестами критичные чистые модули: `config` (парсинг env), `helpers`, `db/utils`, auth-хелперы, проверки прав.
3. Смоук-проверка рантайма: старт сервера, `/api-docs`, healthcheck, 12 защищённых маршрута.
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` (применяются только новые).

View File

@ -0,0 +1,34 @@
# User Progress Backend
## 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.
## API
All routes require JWT authentication.
- `GET /api/user_progress?progress_type=<type>`: returns current user's progress rows for the requested type.
- `GET /api/user_progress?progress_type=<type>&item_id=<id>`: returns current user's progress for one item.
- `POST /api/user_progress`: creates or updates one progress item and returns the saved DTO.
- `DELETE /api/user_progress/by-item?progress_type=<type>&item_id=<id>`: deletes current user's progress for one item.
## Supported Initial Types
- `sign_learned`
- `zone_checkin`
## Data Contract
Required mutation fields:
- `progress_type`
- `item_id`
Optional mutation fields:
- `value`
- `score`
- `metadata`
The backend assigns `organizationId`, `campusId`, and `userId` from the authenticated user. Frontend-provided user names or roles are not trusted for ownership.

View File

@ -0,0 +1,45 @@
# Walk-Through Check-Ins Backend
## 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.
## API
All routes require JWT authentication.
- `GET /api/walkthrough_checkins`: returns check-ins visible to the current manager.
- `GET /api/walkthrough_checkins?teacher_name=<name>`: filters visible check-ins by teacher name.
- `POST /api/walkthrough_checkins`: creates one check-in for the current user's organization and campus.
- `DELETE /api/walkthrough_checkins/:id`: deletes one visible check-in.
## 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.
## Data Contract
Required mutation fields:
- `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`
Optional mutation fields:
- category comments
- `overall_notes`
The backend returns normalized DTO rows with tenant and audit fields.

33
backend/eslint.config.js Normal file
View File

@ -0,0 +1,33 @@
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',
},
},
];

6124
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"description": "School Chain Manager - template backend",
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch",
"lint": "eslint . --ext .js",
"lint": "eslint .",
"db:migrate": "sequelize-cli db:migrate",
"db:seed": "sequelize-cli db:seed:all",
"db:drop": "sequelize-cli db:drop",
@ -11,46 +11,48 @@
"watch": "node watcher.js"
},
"dependencies": {
"@google-cloud/storage": "^5.18.2",
"axios": "^1.6.7",
"bcrypt": "5.1.1",
"chokidar": "^4.0.3",
"cors": "2.8.5",
"csv-parser": "^3.0.0",
"express": "4.18.2",
"formidable": "1.2.2",
"helmet": "4.1.1",
"@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",
"jsonwebtoken": "8.5.1",
"lodash": "4.17.21",
"jsonwebtoken": "9.0.3",
"lodash": "4.18.1",
"moment": "2.30.1",
"multer": "^1.4.4",
"mysql2": "2.2.5",
"nodemailer": "6.9.9",
"multer": "^2.1.1",
"mysql2": "3.22.5",
"nodemailer": "8.0.10",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-jwt": "^4.0.1",
"passport-microsoft": "^0.1.0",
"pg": "8.4.1",
"passport-microsoft": "^2.1.0",
"pg": "8.21.0",
"pg-hstore": "2.3.4",
"sequelize": "6.35.2",
"sequelize": "6.37.8",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4"
"sqlite": "5.1.1",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
"tedious": "^19.2.1"
},
"engines": {
"node": ">=18"
},
"overrides": {
"uuid": "^11.1.1"
},
"private": true,
"devDependencies": {
"cross-env": "7.0.3",
"eslint": "^8.23.1",
"eslint-plugin-import": "^2.29.1",
"mocha": "8.1.3",
"node-mocks-http": "1.9.0",
"nodemon": "2.0.5",
"sequelize-cli": "6.6.2"
"@eslint/js": "^10.0.1",
"cross-env": "10.1.0",
"eslint": "^10.4.1",
"eslint-plugin-import-x": "^4.16.2",
"nodemon": "3.1.14",
"sequelize-cli": "6.6.5"
}
}

View File

@ -1,11 +1,10 @@
const config = require('../config');
const providers = config.providers;
const helpers = require('../helpers');
const db = require('../db/models');
const { extractAccessCookie } = require('./cookies');
const passport = require('passport');
const JWTstrategy = require('passport-jwt').Strategy;
const ExtractJWT = require('passport-jwt').ExtractJwt;
const GoogleStrategy = require('passport-google-oauth2').Strategy;
const MicrosoftStrategy = require('passport-microsoft').Strategy;
const UsersDBApi = require('../db/api/users');
@ -14,7 +13,7 @@ const UsersDBApi = require('../db/api/users');
passport.use(new JWTstrategy({
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken()
jwtFromRequest: extractAccessCookie
}, async (req, token, done) => {
try {
const user = await UsersDBApi.findBy( {email: token.user.email});
@ -56,13 +55,7 @@ passport.use(new MicrosoftStrategy({
));
function socialStrategy(email, profile, provider, done) {
db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => {
const body = {
id: user.id,
email: user.email,
name: profile.displayName,
};
const token = helpers.jwtSign({user: body});
return done(null, {token});
db.users.findOrCreate({where: {email, provider}}).then(([user]) => {
return done(null, {user});
});
}

130
backend/src/auth/cookies.js Normal file
View File

@ -0,0 +1,130 @@
const config = require('../config');
function encodeCookieValue(value) {
return encodeURIComponent(value);
}
function decodeCookieValue(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getBaseCookieOptions(maxAge) {
const options = {
httpOnly: true,
maxAge,
path: config.auth.cookiePath,
sameSite: config.auth.cookieSameSite,
secure: config.auth.cookieSecure,
};
if (config.auth.cookieDomain) {
options.domain = config.auth.cookieDomain;
}
return options;
}
function getClearCookieOptions() {
const options = {
path: config.auth.cookiePath,
};
if (config.auth.cookieDomain) {
options.domain = config.auth.cookieDomain;
}
return options;
}
function getAccessCookieOptions() {
return getBaseCookieOptions(config.auth.accessTokenMaxAgeMs);
}
function getRefreshCookieOptions() {
return getBaseCookieOptions(config.auth.refreshTokenMaxAgeMs);
}
function setAccessCookie(res, token) {
res.cookie(config.auth.accessCookieName, token, getAccessCookieOptions());
}
function setRefreshCookie(res, token) {
res.cookie(config.auth.refreshCookieName, token, getRefreshCookieOptions());
}
function setSessionCookies(res, session) {
setAccessCookie(res, session.accessToken);
setRefreshCookie(res, session.refreshToken);
}
function clearAccessCookie(res) {
res.clearCookie(config.auth.accessCookieName, getClearCookieOptions());
}
function clearRefreshCookie(res) {
res.clearCookie(config.auth.refreshCookieName, getClearCookieOptions());
}
function clearSessionCookies(res) {
clearAccessCookie(res);
clearRefreshCookie(res);
}
function extractCookie(req, cookieName) {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
return null;
}
const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const separatorIndex = cookie.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const name = cookie.slice(0, separatorIndex).trim();
if (name !== cookieName) {
continue;
}
const value = cookie.slice(separatorIndex + 1).trim();
return decodeCookieValue(value);
}
return null;
}
function extractAccessCookie(req) {
return extractCookie(req, config.auth.accessCookieName);
}
function extractRefreshCookie(req) {
return extractCookie(req, config.auth.refreshCookieName);
}
function serializeAccessCookie(token) {
return `${config.auth.accessCookieName}=${encodeCookieValue(token)}`;
}
module.exports = {
clearAccessCookie,
clearRefreshCookie,
clearSessionCookies,
extractAccessCookie,
extractRefreshCookie,
getAccessCookieOptions,
getRefreshCookieOptions,
serializeAccessCookie,
setAccessCookie,
setRefreshCookie,
setSessionCookies,
};

View File

@ -1,34 +1,161 @@
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: "fldemo-files",
hash: "afeefb9d49f5b7977577876b99532ac7"
bucket: process.env.GCLOUD_BUCKET || '',
hash: process.env.GCLOUD_HASH || ''
},
bcrypt: {
saltRounds: 12
saltRounds: BCRYPT_SALT_ROUNDS
},
admin_pass: "c7730661",
user_pass: "dfdc88c68843",
admin_email: "admin@flatlogic.com",
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft'
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: process.env.SECRET_KEY || 'c7730661-4acb-45d3-86c6-dfdc88c68843',
secret_key: requiredEnv('SECRET_KEY'),
remote: '',
port: process.env.NODE_ENV === "production" ? "" : "8080",
hostUI: process.env.NODE_ENV === "production" ? "" : "http://localhost",
portUI: process.env.NODE_ENV === "production" ? "" : "3000",
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: process.env.NODE_ENV === "production" ? "" : ":3000",
portUIProd: isProduction ? "" : `:${process.env.UI_PORT || DEFAULT_DEV_UI_PORT}`,
swaggerUI: process.env.NODE_ENV === "production" ? "" : "http://localhost",
swaggerPort: process.env.NODE_ENV === "production" ? "" : ":8080",
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 || '',
@ -39,9 +166,9 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'School Chain Manager <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
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,
@ -52,18 +179,18 @@ const config = {
},
roles: {
super_admin: 'Super Administrator',
super_admin: GENERATED_ROLE_NAMES.SUPER_ADMIN,
admin: 'Administrator',
admin: GENERATED_ROLE_NAMES.ADMIN,
user: 'Finance Officer',
user: GENERATED_ROLE_NAMES.FINANCE_OFFICER,
},
project_uuid: 'c7730661-4acb-45d3-86c6-dfdc88c68843',
flHost: process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage' ? 'https://flatlogic.com/projects' : 'http://localhost:3000/projects',
project_uuid: process.env.FLATLOGIC_PROJECT_UUID || '',
flHost: isProductionLike ? PRODUCTION_FLATLOGIC_HOST : DEFAULT_FLATLOGIC_HOST,
gpt_key: process.env.GPT_KEY || '',
@ -71,8 +198,8 @@ const config = {
config.pexelsKey = process.env.PEXELS_KEY || '';
config.pexelsQuery = 'Lighthouse guiding ships at dawn';
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
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}` : ``}/#`;

View File

@ -0,0 +1,52 @@
const fs = require('fs');
const path = require('path');
const ENV_FILE = path.resolve(__dirname, '..', '..', '.env');
function parseEnvLine(line) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
return null;
}
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) {
return null;
}
const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();
if (!key) {
return null;
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return { key, value };
}
function loadEnvFile() {
if (!fs.existsSync(ENV_FILE)) {
return;
}
const file = fs.readFileSync(ENV_FILE, 'utf8');
for (const line of file.split(/\r?\n/)) {
const entry = parseEnvLine(line);
if (entry && process.env[entry.key] === undefined) {
process.env[entry.key] = entry.value;
}
}
}
loadEnvFile();

View File

@ -0,0 +1,55 @@
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 <app@flatlogic.app>';
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,
};

View File

@ -0,0 +1,43 @@
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,
};

View File

@ -0,0 +1,63 @@
const {
GENERATED_ROLE_NAMES,
GENERATED_ROLE_TO_PRODUCT_ROLE,
PRODUCT_ROLE_VALUES,
STAFF_TYPE_TO_PRODUCT_ROLE,
} = require('./roles');
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([
...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([
PRODUCT_ROLE_VALUES.OFFICE,
PRODUCT_ROLE_VALUES.DIRECTOR,
PRODUCT_ROLE_VALUES.SUPERINTENDENT,
]);
const CAMPUS_ATTENDANCE_DEFAULT_LIMIT = 120;
const CAMPUS_ATTENDANCE_MAX_LIMIT = 366;
function normalizeCampusKey(value) {
if (typeof value !== 'string' || value.trim().length === 0) {
return null;
}
const normalized = value.trim().toLowerCase().replace(/\s+campus$/u, '');
return normalized || null;
}
function getProductRole(currentUser) {
const roleName = currentUser?.app_role?.name;
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]) {
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,
};

View File

@ -0,0 +1,96 @@
const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([
{
id: '7e15d693-3f7c-4bc6-a399-8345002af8cf',
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,
active: true,
importHash: 'product-campus-tigers',
},
{
id: '6ac9c04e-729d-41b8-9058-cd3aa26b832c',
name: 'Gators Campus',
code: 'gators',
mascot: 'Gators',
color: 'bg-emerald-500',
bgGradient: 'from-emerald-500 to-green-500',
borderColor: 'border-emerald-500/30',
textColor: 'text-emerald-400',
bgLight: 'bg-emerald-500/10',
description: 'Resilience, adaptability & power',
isOnline: false,
active: true,
importHash: 'product-campus-gators',
},
{
id: '829c4d4b-525e-408a-ae7a-0358c50726f7',
name: 'Hawks Campus',
code: 'hawks',
mascot: 'Hawks',
color: 'bg-red-500',
bgGradient: 'from-red-500 to-rose-500',
borderColor: 'border-red-500/30',
textColor: 'text-red-400',
bgLight: 'bg-red-500/10',
description: 'Vision, focus & leadership',
isOnline: false,
active: true,
importHash: 'product-campus-hawks',
},
{
id: '848eb809-b2e2-4c0f-ac6b-cb910fd7e26d',
name: 'Owls Campus',
code: 'owls',
mascot: 'Owls',
color: 'bg-purple-500',
bgGradient: 'from-purple-500 to-violet-500',
borderColor: 'border-purple-500/30',
textColor: 'text-purple-400',
bgLight: 'bg-purple-500/10',
description: 'Wisdom, insight & virtual learning',
isOnline: true,
active: true,
importHash: 'product-campus-owls',
},
{
id: '6670d72a-cf6b-4f92-9e21-378ac81df3d8',
name: 'Wildcats Campus',
code: 'wildcats',
mascot: 'Wildcats',
color: 'bg-blue-500',
bgGradient: 'from-blue-500 to-cyan-500',
borderColor: 'border-blue-500/30',
textColor: 'text-blue-400',
bgLight: 'bg-blue-500/10',
description: 'Agility, independence & spirit',
isOnline: false,
active: true,
importHash: 'product-campus-wildcats',
},
{
id: '4a331c45-b463-4748-9e90-23d0e4b41aaf',
name: 'Grizzlies Campus',
code: 'grizzlies',
mascot: 'Grizzlies',
color: 'bg-amber-700',
bgGradient: 'from-amber-700 to-yellow-600',
borderColor: 'border-amber-700/30',
textColor: 'text-amber-500',
bgLight: 'bg-amber-700/10',
description: 'Strength, protection & community',
isOnline: false,
active: true,
importHash: 'product-campus-grizzlies',
},
]);
module.exports = {
PRODUCT_CAMPUS_SEED_ROWS,
};

View File

@ -0,0 +1,50 @@
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,
};

View File

@ -0,0 +1,13 @@
const { GENERATED_ROLE_NAMES } = require('./roles');
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 = {
CONTENT_CATALOG_MANAGER_ROLE_NAMES,
};

View File

@ -0,0 +1,13 @@
const { GENERATED_ROLE_NAMES } = require('./roles');
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 = {
FRAME_EDITOR_ROLE_NAMES,
};

View File

@ -0,0 +1,13 @@
const { GENERATED_ROLE_NAMES } = require('./roles');
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 = {
PERSONALITY_REPORT_ROLE_NAMES,
};

View File

@ -0,0 +1,40 @@
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,
};

View File

@ -0,0 +1,13 @@
const { GENERATED_ROLE_NAMES } = require('./roles');
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 = {
SAFETY_QUIZ_REPORT_ROLE_NAMES,
};

View File

@ -0,0 +1,32 @@
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,
};

View File

@ -0,0 +1,11 @@
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,
};

View File

@ -0,0 +1,21 @@
const { GENERATED_ROLE_NAMES } = require('./roles');
const WALKTHROUGH_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 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,
};

View File

@ -0,0 +1,56 @@
const db = require('../models');
module.exports = class AuthRefreshTokensDBApi {
static async create(data, options = {}) {
return db.auth_refresh_tokens.create(
{
userId: data.userId,
organizationId: data.organizationId || null,
tokenHash: data.tokenHash,
familyId: data.familyId,
previousTokenId: data.previousTokenId || null,
userAgent: data.userAgent || null,
ipAddress: data.ipAddress || null,
expiresAt: data.expiresAt,
revokedAt: null,
replacedByTokenId: null,
},
{ transaction: options.transaction },
);
}
static async findByHash(tokenHash, options = {}) {
return db.auth_refresh_tokens.findOne({
where: { tokenHash },
transaction: options.transaction,
});
}
static async revoke(id, replacedByTokenId, options = {}) {
return db.auth_refresh_tokens.update(
{
revokedAt: new Date(),
replacedByTokenId: replacedByTokenId || null,
},
{
where: { id },
transaction: options.transaction,
},
);
}
static async revokeFamily(familyId, options = {}) {
return db.auth_refresh_tokens.update(
{
revokedAt: new Date(),
},
{
where: {
familyId,
revokedAt: null,
},
transaction: options.transaction,
},
);
}
};

View File

@ -45,6 +45,46 @@ module.exports = class CampusesDBApi {
||
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
||
@ -104,6 +144,46 @@ module.exports = class CampusesDBApi {
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
@ -152,6 +232,22 @@ module.exports = class CampusesDBApi {
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;
@ -553,4 +649,3 @@ module.exports = class CampusesDBApi {
};

View File

@ -1,4 +1,10 @@
require('../config/load-env');
const {
DEFAULT_DEV_DB_HOST,
DEFAULT_DEV_DB_NAME,
DEFAULT_DEV_DB_USER,
} = require('../constants/app');
module.exports = {
production: {
@ -12,11 +18,12 @@ module.exports = {
seederStorage: 'sequelize',
},
development: {
username: 'postgres',
username: process.env.DB_USER || DEFAULT_DEV_DB_USER,
dialect: 'postgres',
password: '',
database: 'db_school_chain_manager',
host: process.env.DB_HOST || 'localhost',
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',
},

View File

@ -0,0 +1,151 @@
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;
}
},
};

View File

@ -0,0 +1,158 @@
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;
}
},
};

View File

@ -0,0 +1,174 @@
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;
}
},
};

View File

@ -0,0 +1,127 @@
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;
}
},
};

View File

@ -0,0 +1,145 @@
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;
}
},
};

View File

@ -0,0 +1,160 @@
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;
}
},
};

View File

@ -0,0 +1,141 @@
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;
}
},
};

View File

@ -0,0 +1,166 @@
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;
}
},
};

View File

@ -0,0 +1,170 @@
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;
}
},
};

View File

@ -0,0 +1,148 @@
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;
}
},
};

View File

@ -0,0 +1,58 @@
'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)
)));
},
};

View File

@ -0,0 +1,53 @@
'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');
},
};

View File

@ -0,0 +1,77 @@
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;
};

View File

@ -0,0 +1,62 @@
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;
};

View File

@ -0,0 +1,87 @@
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;
};

View File

@ -47,6 +47,64 @@ email: {
},
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: {
@ -198,4 +256,3 @@ active: {
return campuses;
};

View File

@ -0,0 +1,67 @@
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;
};

View File

@ -0,0 +1,38 @@
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;
};

View File

@ -0,0 +1,82 @@
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;
};

View File

@ -0,0 +1,70 @@
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;
};

View File

@ -0,0 +1,94 @@
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;
};

View File

@ -0,0 +1,81 @@
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;
};

View File

@ -0,0 +1,78 @@
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;
};

View File

@ -0,0 +1,130 @@
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;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
'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),
},
});
},
};

View File

@ -0,0 +1,63 @@
'use strict';
const { CONTENT_CATALOG_SEED_PAYLOADS } = require('./content-catalog-data/content-catalog-seed-payloads');
const CONTENT_CATALOG_SEED_ROWS = Object.freeze([
{ content_type: 'classroom-strategies', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomStrategies },
{ content_type: 'safety-qbs-quiz', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyQbsQuiz },
{ content_type: 'sign-language-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguageItems },
{ content_type: 'sign-language-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.signLanguagePageContent },
{ content_type: 'regulation-zones', payload: CONTENT_CATALOG_SEED_PAYLOADS.regulationZones },
{ content_type: 'zones-of-regulation-page-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.zonesOfRegulationPageContent },
{ content_type: 'dashboard-teacher-images', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardTeacherImages },
{ content_type: 'dashboard-encouraging-quotes', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardEncouragingQuotes },
{ content_type: 'dashboard-compliance-items', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardComplianceItems },
{ content_type: 'dashboard-sign-of-week', payload: CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek },
{ content_type: 'parent-message-templates', payload: CONTENT_CATALOG_SEED_PAYLOADS.parentMessageTemplates },
{ content_type: 'community-organizations', payload: CONTENT_CATALOG_SEED_PAYLOADS.communityOrganizations },
{ content_type: 'vocational-opportunities', payload: CONTENT_CATALOG_SEED_PAYLOADS.vocationalOpportunities },
{ content_type: 'emotional-intelligence-assessment-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceAssessmentQuestions },
{ content_type: 'emotional-intelligence-weekly-topics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyTopics },
{ content_type: 'emotional-intelligence-growth-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceGrowthTips },
{ content_type: 'emotional-intelligence-team-wellness-metrics', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceTeamWellnessMetrics },
{ content_type: 'personality-quiz-questions', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizQuestions },
{ content_type: 'personality-types', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityTypes },
{ content_type: 'esa-funding-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent },
{ content_type: 'safety-protocols', payload: CONTENT_CATALOG_SEED_PAYLOADS.safetyProtocols },
{ content_type: 'classroom-timer-backgrounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerBackgrounds },
{ content_type: 'classroom-timer-sounds', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerSounds },
{ content_type: 'classroom-timer-presets', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerPresets },
{ content_type: 'classroom-timer-tips', payload: CONTENT_CATALOG_SEED_PAYLOADS.classroomTimerTips },
{ content_type: 'personality-quiz-features', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityQuizFeatures },
{ content_type: 'emotional-intelligence-weekly-focus', payload: CONTENT_CATALOG_SEED_PAYLOADS.emotionalIntelligenceWeeklyFocus },
{ content_type: 'personality-workplace-content', payload: CONTENT_CATALOG_SEED_PAYLOADS.personalityWorkplaceContent },
]);
module.exports = {
up: async (queryInterface, Sequelize) => {
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,
},
});
await queryInterface.bulkInsert('content_catalog', CONTENT_CATALOG_SEED_ROWS.map((row) => ({
...row,
active: true,
importHash: 'content-catalog-' + row.content_type,
createdAt: now,
updatedAt: now,
})));
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('content_catalog', {
content_type: {
[Sequelize.Op.in]: CONTENT_CATALOG_SEED_ROWS.map((row) => row.content_type),
},
});
},
};

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const config = require('./config');
const { JWT_EXPIRES_IN } = require('./constants/auth');
module.exports = class Helpers {
static wrapAsync(fn) {
@ -18,6 +19,6 @@ module.exports = class Helpers {
}
static jwtSign(data) {
return jwt.sign(data, config.secret_key, {expiresIn: '6h'});
return jwt.sign(data, config.secret_key, {expiresIn: JWT_EXPIRES_IN});
};
};

View File

@ -6,8 +6,9 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
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');
@ -16,6 +17,9 @@ 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');
@ -74,6 +78,14 @@ 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) => {
@ -122,14 +134,27 @@ app.use('/api-docs', function (req, res, next) {
next()
}, swaggerUI.serve, swaggerUI.setup(specs))
app.use(cors({origin: true}));
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');
@ -185,6 +210,19 @@ app.use('/api/message_recipients', passport.authenticate('jwt', {session: false}
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 }),
@ -226,7 +264,7 @@ if (fs.existsSync(publicDir)) {
});
}
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
const PORT = config.serverPort;
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);

View File

@ -0,0 +1,34 @@
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;

View File

@ -6,6 +6,11 @@ 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();
@ -58,10 +63,29 @@ const router = express.Router();
*/
router.post('/signin/local', wrapAsync(async (req, res) => {
const payload = await AuthService.signin(req.body.email, req.body.password, req,);
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:
@ -79,15 +103,14 @@ router.post('/signin/local', wrapAsync(async (req, res) => {
* x-codegen-request-body-name: body
*/
router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) => {
router.get('/me', passport.authenticate('jwt', {session: false}), wrapAsync(async (req, res) => {
if (!req.currentUser || !req.currentUser.id) {
throw new ForbiddenError();
}
const payload = req.currentUser;
delete payload.password;
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,);
@ -141,7 +164,7 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => {
router.post('/signup', wrapAsync(async (req, res) => {
const link = new URL(req.headers.referer);
const payload = await AuthService.signup(
const result = await AuthService.signup(
req.body.email,
req.body.password,
@ -150,6 +173,9 @@ router.post('/signup', wrapAsync(async (req, res) => {
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);
}));
@ -179,10 +205,10 @@ router.get('/signin/google', (req, res, next) => {
router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}),
function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config);
wrapAsync(async (req, res) => {
await socialRedirect(req, res, req.user.user, config);
}
);
));
router.get('/signin/microsoft', (req, res, next) => {
passport.authenticate("microsoft", {
@ -195,15 +221,17 @@ router.get('/signin/microsoft/callback', passport.authenticate("microsoft", {
failureRedirect: "/login",
session: false
}),
function (req, res) {
socialRedirect(res, req.query.state, req.user.token, config);
wrapAsync(async (req, res) => {
await socialRedirect(req, res, req.user.user, config);
}
);
));
router.use('/', require('../helpers').commonErrorHandler);
function socialRedirect(res, state, token, config) {
res.redirect(config.uiUrl + "/login?token=" + token);
async function socialRedirect(req, res, user, config) {
const session = await AuthService.createSession(user, req);
setSessionCookies(res, session);
res.redirect(config.uiUrl);
}
module.exports = router;

View File

@ -0,0 +1,34 @@
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;

View File

@ -0,0 +1,29 @@
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;

View File

@ -0,0 +1,35 @@
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;

View File

@ -0,0 +1,24 @@
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;

View File

@ -0,0 +1,24 @@
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;

View File

@ -1,18 +1,41 @@
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 || 'nature';
const orientation = 'portrait';
const perPage = 1;
const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`;
const query = pexelsQuery;
const url = buildPexelsSearchUrl(PEXELS_IMAGE_SEARCH_URL, query, PEXELS_IMAGE_ORIENTATION);
try {
const response = await fetch(url, { headers });
@ -27,10 +50,8 @@ router.get('/video', async (req, res) => {
const headers = {
Authorization: `${KEY}`,
};
const query = pexelsQuery || 'nature';
const orientation = 'portrait';
const perPage = 1;
const url = `https://api.pexels.com/videos/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`;
const query = pexelsQuery;
const url = buildPexelsSearchUrl(PEXELS_VIDEO_SEARCH_URL, query, PEXELS_VIDEO_ORIENTATION);
try {
const response = await fetch(url, { headers });
@ -48,29 +69,22 @@ router.get('/multiple-images', async (req, res) => {
const queries = req.query.queries
? req.query.queries.split(',')
: ['home', 'apple', 'pizza', 'mountains', 'cat'];
const orientation = 'square';
const perPage = 1;
: PEXELS_MULTIPLE_IMAGE_DEFAULT_QUERIES;
const fallbackImage = {
src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg',
photographer: 'Yan Krukau',
photographer_url: 'https://www.pexels.com/@yankrukov',
};
const fetchFallbackImage = async () => {
try {
const response = await fetch('https://picsum.photos/600');
const response = await fetch(PICSUM_FALLBACK_URL);
return {
src: response.url,
photographer: 'Random Picsum',
photographer_url: 'https://picsum.photos/',
photographer: PICSUM_FALLBACK_PHOTOGRAPHER,
photographer_url: PICSUM_FALLBACK_URL,
};
} catch (error) {
return fallbackImage;
return PEXELS_FALLBACK_IMAGE;
}
};
const fetchImage = async (query) => {
const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`;
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;
@ -83,15 +97,15 @@ router.get('/multiple-images', async (req, res) => {
if (result.status === 'fulfilled' && result.value) {
const image = result.value;
return {
src: image.src?.original || fallbackImage.src,
photographer: image.photographer || fallbackImage.photographer,
photographer_url: image.photographer_url || fallbackImage.photographer_url,
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: fallback.photographer || UNKNOWN_PHOTOGRAPHER_LABEL,
photographer_url: fallback.photographer_url || '',
};
}

View File

@ -0,0 +1,13 @@
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;

View File

@ -0,0 +1,13 @@
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;

View File

@ -0,0 +1,19 @@
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;

View File

@ -0,0 +1,19 @@
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;

View File

@ -0,0 +1,24 @@
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;

View File

@ -0,0 +1,24 @@
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;

View File

@ -1,15 +1,312 @@
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});
@ -44,14 +341,9 @@ class Auth {
);
}
const data = {
user: {
id: user.id,
email: user.email
}
return {
user,
};
return helpers.jwtSign(data);
}
const newUser = await UsersDBApi.createFromAuth(
@ -73,17 +365,12 @@ class Auth {
);
}
const data = {
user: {
id: newUser.id,
email: newUser.email
}
return {
user: newUser,
};
return helpers.jwtSign(data);
}
static async signin(email, password, options = {}) {
static async signin(email, password) {
const user = await UsersDBApi.findBy({email});
if (!user) {
@ -125,14 +412,9 @@ class Auth {
);
}
const data = {
user: {
id: user.id,
email: user.email
}
return {
user,
};
return helpers.jwtSign(data);
}
static async sendEmailAddressVerificationEmail(
@ -293,14 +575,10 @@ class Auth {
{transaction},
);
await UsersDBApi.update(
currentUser.id,
data,
{
currentUser,
transaction
},
);
await UsersDBApi.update(currentUser.id, data, null, {
currentUser,
transaction
});
await transaction.commit();

View File

@ -0,0 +1,374 @@
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;
}
}
};

View File

@ -0,0 +1,48 @@
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,
};
}
};

View File

@ -0,0 +1,285 @@
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);
}
};

View File

@ -0,0 +1,172 @@
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;
}
}
};

View File

@ -0,0 +1,184 @@
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;
}
}
};

View File

@ -0,0 +1,173 @@
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,
};
}
};

View File

@ -0,0 +1,155 @@
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;
}
}
};

View File

@ -0,0 +1,192 @@
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,
};
}
};

View File

@ -0,0 +1,160 @@
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 };
}
};

View File

@ -0,0 +1,210 @@
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 };
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
# Dependency Baseline
## Purpose
This document records the active dependency baseline for the project after upgrading runtime and tooling packages.
## Active Applications
The active applications are:
- `frontend/`
- `backend/`
Both active applications use npm lockfiles:
- `frontend/package-lock.json`
- `backend/package-lock.json`
The root production scripts use npm commands. Do not add Yarn lockfiles back to the active apps unless the package-manager decision is explicitly changed.
## Frontend Baseline
The frontend dependency baseline has been updated to current stable npm versions for the active Vite app.
Key tooling/runtime updates:
- React 19
- Vite 8
- TypeScript 6
- Tailwind 4 with `@tailwindcss/postcss`
- Vitest 4
- ESLint 10
- `@vitejs/plugin-react`
- Playwright for frontend smoke tests
Verification:
- `npm run lint` passes.
- `npm run test` passes.
- `npm run test:e2e` passes when a local browser install is available.
- `npm run build` passes and runs typecheck before Vite.
- `npm audit --audit-level=low` reports 0 vulnerabilities.
- `npm outdated` reports no outdated stable dependencies.
## Backend Baseline
The backend dependency baseline has been updated to current stable npm versions for the active Express app.
Key tooling/runtime updates:
- Express 5
- bcrypt 6
- helmet 8
- jsonwebtoken 9
- Sequelize 6.37
- ESLint 10 flat config
- `eslint-plugin-import-x` for unresolved import checks with ESLint 10
The backend uses an npm `overrides` entry for `uuid` so transitive dependency trees resolve to the patched stable line.
Verification:
- `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 baseline.
- `npm run lint` still fails on existing generated/template code debt. The ESLint 10 `.eslintignore` warning is resolved, and the remaining lint failures should be fixed as backend cleanup instead of hidden with broad ignores.
## Reference Frontend
`ref-frontend/` is a temporary reference artifact, not the active runtime frontend. Keep it frozen until integration work no longer needs it, then delete it.

175
docs/development-path.md Normal file
View File

@ -0,0 +1,175 @@
# Development Path
## Context
The repository currently contains three relevant parts:
- `backend/`: generated Node.js/Express backend with PostgreSQL, Sequelize models, JWT auth, roles, permissions, email services, file handling, Swagger, and school-domain entities.
- `frontend/`: customer-approved Vite/React interface with Tailwind, Radix/shadcn UI, React Query, role-based modules, backend API integration, and intentionally static product content. This is the frontend that will be developed going forward.
- `ref-frontend/`: generated Next.js frontend with CRUD pages and Redux slices for the generated backend entities. This is a temporary reference only and can be removed after the working frontend is fully integrated.
The customer-approved interface defines the expected product experience. The generated backend already covers much of the SaaS infrastructure and core school-chain data model.
## Decision
Use `frontend/` as the product frontend base and keep `backend/` as the backend base.
Do not continue building on the generated Next.js frontend UI in `ref-frontend/`. Its most useful parts are API usage patterns, auth flow, entity coverage, and contracts with the generated backend. The visual layer and user workflows should come from `frontend/`.
## Rationale
This path is the least risky and most direct because:
- The approved UI is already in `frontend/`; rebuilding it inside the generated reference frontend would duplicate work and risk visual drift.
- The generated backend already has entities for organizations, campuses, staff, students, guardians, classes, attendance, assessments, messages, documents, roles, permissions, invoices, payments, and email flows.
- The generated reference frontend uses a different stack from the approved UI: Next.js, MUI, Redux, and i18n. The active approved UI uses Vite, React 19, Tailwind, Radix/shadcn, React Query, and lucide icons.
- The approved UI must use the backend for authentication, tenant scoping, permissions, and persisted data ownership.
- Replacing the generated frontend with the approved UI plus a typed API client preserves the approved UX while keeping SaaS infrastructure centralized in the existing backend.
## Migration Plan
### 1. Frontend Replacement
The generated `frontend/` application has been moved to `ref-frontend/`, and the approved UI has been moved into `frontend/`.
Keep and adapt:
- Tailwind/Radix/shadcn UI components.
- Customer-approved module layout and styling.
- Role-based module navigation.
- React Query-based data loading.
- Vite build pipeline unless deployment constraints require Next.js.
Remove or replace:
- Direct browser-to-database access from app code.
- Demo role switcher in production mode.
- Silent mock/static data behavior for data that must be persisted.
- Inline `console.error` handling in data functions.
- Directly embedded demo users and static campus assumptions where backend data should be authoritative.
### 2. Frontend API Layer
Create a dedicated frontend API layer that talks to `backend/`.
Recommended structure:
- `frontend/src/shared/api/httpClient.ts`
- `frontend/src/shared/api/auth.ts`
- `frontend/src/shared/api/campuses.ts`
- `frontend/src/shared/api/staff.ts`
- `frontend/src/shared/api/frameworks.ts`
- `frontend/src/shared/api/attendance.ts`
- `frontend/src/shared/api/messages.ts`
- `frontend/src/shared/api/documents.ts`
Use aliases with `@` for all imports. Keep response and request types strict. Do not introduce `any` or casts.
### 3. Backend Alignment
Keep the generated backend and extend it where the approved UI has modules that do not map cleanly to existing entities.
Existing backend coverage:
- Multi-tenant base: `organizations` and organization links across generated entities.
- Campuses: `campuses`.
- Staff and users: `staff`, `users`, `roles`, `permissions`.
- Attendance: `attendance_sessions`, `attendance_records`.
- Communication: `messages`, `message_recipients`.
- Documents and policies: `documents`, `file`.
- Academic records: `classes`, `students`, `guardians`, `assessments`, `assessment_results`.
- Finance-related base: `fee_plans`, `invoices`, `payments`.
Likely new backend modules needed:
- FRAME weekly entries.
- Staff zone check-ins and progress.
- Safety quiz results.
- Personality quiz results.
- Walk-through check-ins.
- Campus attendance summaries/configuration if the existing attendance model is not enough.
- Content catalog for classroom strategies, signs, handbook/policies, community services, vocational opportunities, and ESA content if these need admin editing.
### 4. Multi-Tenancy
Use `organizations` as the tenant boundary.
Required work:
- Ensure every tenant-owned query is scoped by `currentUser.organizationsId` 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.
### 5. Auth And Roles
Use backend JWT auth as the source of truth.
Map approved UI roles to backend roles/permissions:
- `teacher`
- `para`
- `office`
- `director`
- `superintendent`
Required work:
- Keep auth context backed by the backend JWT API.
- Load current user from `/api/auth/me`.
- Derive role and campus from backend user/staff profile.
- Enforce access both in frontend navigation and backend permissions.
- Keep UI route hiding as convenience only; backend authorization must be authoritative.
### 6. Error Handling
Backend:
- Use centralized exception classes instead of ad hoc errors.
- Remove direct logging from business logic where centralized error handling should own observability.
Frontend:
- Replace silent returns such as `return []`, `return false`, or `return null` after failed API calls with explicit error states.
- Show user-facing errors using the existing toast/error UI.
- Keep native external-service errors from Gemini/OpenAI unchanged where applicable.
### 7. Documentation
For each migrated module:
- Add or update backend docs in `backend/docs/`.
- Add frontend module docs in `frontend/docs/`.
- Document API contracts, permissions, tenant behavior, and known operational assumptions.
## Implementation Order
1. Make the Vite React application in `frontend/` build in place.
2. Add frontend shared constants and aliases.
3. Keep frontend auth integrated with backend JWT auth.
4. Add typed HTTP client and convert one vertical slice end-to-end, starting with campuses/staff/profile.
5. Convert role navigation to backend roles and permissions.
6. Convert FRAME weekly entries.
7. Convert attendance and walk-through modules.
8. Convert messaging, safety quiz results, personality quiz results, progress, and document/policy modules.
9. Add tenant isolation tests and API integration tests.
10. Remove `ref-frontend/` after the product frontend no longer needs the generated reference contracts.
## Rejected Alternative
Adapting the generated Next.js frontend in `ref-frontend/` to look and behave like the product frontend is not recommended.
Reasons:
- It preserves generated CRUD pages that are not the approved customer experience.
- It requires rebuilding the approved interaction model in a different UI stack.
- It keeps more generated code alive than needed.
- It increases the chance of divergence between the approved interface and the shipped product.
## Open Questions
- Should the production frontend remain Vite, or is Next.js required by hosting/deployment constraints?
- Which product frontend modules must be editable by admins versus shipped as static curated content?
- Which product modules should be implemented immediately after auth/profile and staff profile integration?
- What is the final role naming expected in user-facing copy?
- Which email flows are required for launch: invite, verification, password reset, parent communication, staff notifications?

View File

@ -0,0 +1,478 @@
# Full Integration Refactor Plan
## Purpose
This document is the active cross-application backlog for integrating `frontend/`, `backend/`, and the PostgreSQL database.
It must track only unfinished integration work and hard implementation rules. Completed migration history belongs in the feature-specific docs under `backend/docs/`, `frontend/docs/`, and supporting audit docs.
When an item is completed, remove it from this plan and update the owning feature documentation instead of adding a completed checklist entry here.
## Current Application Shape
- `frontend/` is the only product frontend.
- `backend/` is the source of truth for auth, tenant ownership, roles, permissions, users, staff profiles, campuses, persisted workflows, content catalogs, email, files, and database access.
- `ref-frontend/` is a temporary generated reference. It is not runtime code and must be deleted after all needed endpoint-contract reference value is exhausted.
## Non-Negotiable Rules
- No frontend persisted workflow may use mock, sample, seed, or fallback records.
- No frontend runtime code may import backend seed files.
- Frontend runtime constants may contain only UI config, route/module metadata, query keys, timing values, style tokens, static UI labels, and intentionally static product copy.
- Backend seeds are for database seeding only.
- Frontend seed data is allowed only in tests or `frontend/src/test-seeds/`.
- Backend owns tenant scoping and permissions. Frontend route hiding is UX only.
- All frontend backend calls go through typed modules in `frontend/src/shared/api/`.
- New frontend workflows must follow `View -> Business Logic -> API/Data Access -> Backend`.
- View components must not import `frontend/src/shared/api/`.
- Business logic must not import view components.
- API modules must not import business or view code.
- All imports use the `@` alias.
- No `any`, unsafe casts, disabled TypeScript rules, disabled ESLint rules, compatibility bypasses, silent failures, or legacy re-export surfaces.
- Frontend auth must use backend-owned HttpOnly cookies only. Access and refresh tokens must never be stored in frontend browser storage.
- Secrets live only in backend `.env` or deployment environment variables. Frontend env values must be public browser-safe values only.
- New backend modules must include migration/model/service/route, tenant and role enforcement, docs, and focused verification.
- Every changed workflow must update the relevant docs before it is considered complete.
## Current Verification Baseline
Frontend current baseline:
- `npm run typecheck` passes.
- `npm run lint` passes.
- `npm run test` passes with 51 files and 198 tests.
- `npm run build` passes.
- `npm run test:e2e` passes with 4 backend-free Playwright smoke tests.
- `npm run test:e2e:content` exists for backend-seeded content catalog integration tests. It requires backend migrations, backend seeders, and a running backend server.
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.
## Active Workstreams
### 1. Backend Migration And Runtime Verification
Status: open.
Problem:
Backend product modules exist, but the configured local database migration/seed run has not been verified as a passing gate in this plan.
Required work:
1. Run `npm run db:migrate` against the configured local database.
2. Run `npm run db:seed`.
3. Start backend with the documented env file.
4. Verify public content catalog routes, auth routes, and product module routes respond.
5. Record exact failing migration/seed/runtime errors if any.
Acceptance criteria:
- `npm run db:migrate` passes.
- `npm run db:seed` passes.
- Backend starts without generated default secrets.
- `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
Status: open and high priority.
Problem:
Generated backend code has partial tenant scoping. Multi-tenant correctness cannot rely on frontend filtering.
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.
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.
Acceptance criteria:
- Tenant isolation tests cover users, campuses, staff, students, attendance, messages, documents, and product module tables.
- 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
Status: open.
Problem:
Backend currently maps generated roles to product roles. It is not yet a final product decision whether this mapping is the supported model or product roles should become first-class persisted backend roles/permissions.
Required decision:
- Option A: keep generated-role to product-role mapping as the supported model.
- Option B: create first-class persisted product roles: `Teacher`, `Para`, `Office Manager`, `Director`, `Superintendent`.
Required work after decision:
1. Document the chosen role model.
2. Align `/api/auth/me` permissions and product role payload with the chosen model.
3. Add backend role/permission tests for staff, director, and superintendent paths.
4. Keep frontend navigation driven by backend role/permissions.
Acceptance criteria:
- Role model is documented in backend docs.
- Backend tests prove role access for product modules.
- No frontend role grants capability that backend does not enforce.
### 5. Product Onboarding Contract
Status: blocked by customer decision.
Problem:
Generated auth signup/profile endpoints exist, but they do not define the product workflow for company creation, campus creation, user creation, staff profile creation, role assignment, campus assignment, or profile updates.
No-go rules:
- Do not implement frontend registration.
- Do not implement profile creation/editing UI.
- Do not treat generated signup/profile endpoints as the product onboarding contract.
- Do not add temporary compatibility paths.
Required customer decisions:
1. Who creates a company.
2. Who creates campuses.
3. Who invites or creates users.
4. How staff profiles are created and linked to users.
5. How roles and campuses are assigned.
6. Which profile fields users may update themselves.
7. Which profile fields require director/superintendent/admin permissions.
Required work after decision:
1. Add backend onboarding contract and docs.
2. Add tenant-scoped backend workflows.
3. Add frontend typed API modules.
4. Add frontend business hooks.
5. Add UI only after backend contract exists.
6. Add authenticated Playwright workflows using backend-seeded fixtures.
Acceptance criteria:
- Onboarding workflow is customer-approved and documented.
- Backend owns all creation, assignment, and permission checks.
- Frontend only exposes flows backed by typed backend contracts.
### 6. Refresh Token Maintenance
Status: open.
Problem:
Cookie auth and refresh rotation exist, but scheduled cleanup for expired/revoked refresh-token rows remains unresolved.
Required work:
1. Define refresh-token retention period.
2. Add cleanup job or operational command.
3. Ensure cleanup is observable.
4. Document operational usage.
Acceptance criteria:
- Expired and revoked refresh-token rows are cleaned after the approved retention window.
- Cleanup failures are visible and not silent.
- Auth behavior remains unchanged for valid sessions.
### 7. API Documentation Hardening
Status: open.
Problem:
Markdown docs exist for migrated modules, but Swagger/OpenAPI coverage for product-specific and cookie-session endpoints is incomplete.
Required work:
1. Document `/api/auth/signin/local`.
2. Document `/api/auth/refresh`.
3. Document `/api/auth/signout`.
4. Document `/api/auth/me`.
5. Document product module endpoints that are not covered by generated Swagger output.
6. Document response and error shapes per endpoint.
Acceptance criteria:
- API docs match actual route payloads and response shapes.
- Cookie auth behavior is explicit.
- Frontend API contract tests remain aligned with docs.
### 8. Policy And Safety Acknowledgment Persistence
Status: open pending product contract.
Problem:
Policy content is document-backed, but policy/protocol acknowledgments are not yet persisted.
Required decisions:
1. Which policies/protocols require acknowledgment.
2. Which roles must acknowledge.
3. Whether acknowledgments are per document version.
4. Who can report acknowledgment status.
Required work after decision:
1. Add acknowledgment backend model/migration/service/route.
2. Enforce tenant and role scope.
3. Add frontend typed API and business workflow.
4. Add report views only if required.
Acceptance criteria:
- Acknowledgments survive reload.
- Acknowledgment status is tenant-scoped.
- Unauthorized roles cannot view individual acknowledgment records.
### 9. Attendance Source Contracts
Status: partially open.
Current state:
- Campus attendance daily aggregate summaries are implemented for the current UI.
- Staff attendance snapshot/reporting is read-only.
- Student/class attendance source-of-truth workflow is not defined.
Open decisions:
1. Whether campus attendance aggregates are manually entered, imported, or derived from student attendance records.
2. Which external or internal source owns staff attendance writes/imports.
3. Whether student-level attendance UI is required.
Required work after decision:
1. Add write/import endpoints only after source contract exists.
2. Keep derived summaries server-side if summaries are derived.
3. Add backend tests for source-of-truth calculations.
4. Add frontend workflows only after backend contracts exist.
Acceptance criteria:
- Attendance source of truth is documented.
- 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
Status: open pending provider decision.
Problem:
Classroom timer uses built-in Web Audio sounds. AI-generated audio UI is intentionally not exposed because no backend audio provider contract exists.
No-go rules:
- Do not add generated audio UI.
- Do not call external audio providers from frontend runtime.
- Do not add frontend API keys or provider secrets.
Required work after decision:
1. Define backend provider contract.
2. Keep provider secrets in backend env.
3. Add backend service with native provider errors where required.
4. Add typed frontend API and explicit loading/error states.
Acceptance criteria:
- Generated audio is backend-mediated.
- No provider secret reaches the browser.
- Provider failures remain visible.
### 11. File Upload And Download Permissions
Status: open.
Problem:
Backend file and document routes exist. Product file workflows need explicit permission verification before new upload/download UI is added.
Required work:
1. Audit upload permissions.
2. Audit download permissions.
3. Verify tenant and document ownership checks.
4. Add typed frontend upload client only for approved workflows.
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
Status: open.
Problem:
Public routes must be intentionally public. This includes public content catalog routes and any generated/template public integrations.
Required work:
1. List all routes without auth middleware.
2. Mark each as public-by-design or requiring auth.
3. Add auth where required.
4. Document intentionally public routes.
Acceptance criteria:
- Every unauthenticated backend route is documented.
- No tenant-owned data is exposed through accidental public routes.
### 13. Backend-Seeded Authenticated E2E
Status: blocked by onboarding/profile fixtures.
Problem:
Current Playwright coverage includes backend-free smoke tests and backend-seeded content catalog tests. Authenticated persisted workflows need backend-seeded users, roles, campuses, staff profiles, and known credentials.
Required prerequisites:
1. Product onboarding/profile contract.
2. Backend-seeded auth fixtures.
3. Tenant-scoped campus/staff fixtures.
4. Stable test credentials stored only in ignored local env or dedicated test seed config.
Required workflows after prerequisites:
1. Login to dashboard.
2. Director creates or edits a FRAME entry and sees it after reload.
3. Staff completes QBS quiz and director/superintendent sees compliance.
4. Office enters campus attendance and superintendent sees aggregate.
5. Director submits walkthrough and sees summary update.
6. Staff marks a sign learned and progress persists after reload.
Acceptance criteria:
- Authenticated e2e tests use backend seeds, not frontend mock data.
- Tests do not require production secrets.
- Tests are documented and repeatable.
### 14. Accessibility Test Coverage
Status: open.
Required work:
1. Add axe/Playwright accessibility checks for login.
2. Add axe/Playwright checks for dashboard.
3. Add checks for sidebar navigation.
4. Add checks for modal dialogs.
5. Add checks for forms with validation.
6. Add checks for tables/reports.
Acceptance criteria:
- Accessibility tests run in a documented command.
- Critical violations block completion of the refactor.
### 15. `ref-frontend/` Removal
Status: open.
Required work:
1. Confirm no active docs require `ref-frontend/` for endpoint contract reference.
2. Confirm no scripts import or run `ref-frontend/`.
3. Delete `ref-frontend/`.
4. Update root docs and any setup docs.
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
Status: open. Deferred — run after the backend TypeScript/ESM migration (see `backend/docs/typescript-esm-migration-plan.md`, decision 0.7).
Problem:
Social login uses `passport-google-oauth2` (0.2.0, last published 2022) and `passport-microsoft` (2.1.0). The Google strategy is low-maintenance and should be modernized, ideally consolidating both providers on a single maintained OAuth/OIDC library.
Required work:
1. Choose target: minimal swap to `passport-google-oauth20`, or consolidate both providers on `openid-client`.
2. Replace the Google (and optionally Microsoft) passport strategy in `backend/src/auth/auth.ts`.
3. Keep the existing cookie-based session and JWT issuance unchanged.
4. Verify callback URLs, scopes, and the social-signup user flow end to end.
5. Update auth docs (`backend/docs/auth-profile.md`, `cookie-auth.md`).
Acceptance criteria:
- Google and Microsoft sign-in work end to end with the chosen library.
- No deprecated/unmaintained OAuth strategy remains in dependencies.
- Cookie/JWT auth behavior is unchanged for existing sessions.
- This change is isolated from the language/module migration (separate PR/task).
## 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).
## Definition Of Done
The integration refactor is complete only when all of the following are true:
- Backend migrations and seeders pass on the configured local database.
- Backend lint passes without broad ignores.
- Backend tenant isolation tests prove cross-tenant data is not visible or mutable.
- Backend role/permission tests cover product staff, director, and superintendent paths.
- Every unauthenticated backend route is documented as public-by-design.
- Product onboarding contract is customer-approved or explicitly excluded from release scope.
- Every visible frontend workflow is backed by a typed backend API contract.
- No frontend runtime workflow depends on mock, sample, seed, or fallback records for persisted behavior.
- Frontend `typecheck`, `lint`, `test`, `build`, `test:e2e`, and documented seeded e2e suites pass in their required environments.
- Backend auth/API docs match actual route behavior.
- Required accessibility checks pass.
- `ref-frontend/` is deleted or a dated, explicit exception explains why it is still needed.

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