221 lines
10 KiB
Markdown
221 lines
10 KiB
Markdown
# Backend Architecture
|
|
|
|
The backend uses a three-layer architecture, mirroring the frontend
|
|
(`frontend/docs/frontend-architecture.md`):
|
|
|
|
- API layer (HTTP)
|
|
- Business logic layer (BLL)
|
|
- Data access layer (DAL)
|
|
|
|
The goal is to keep routes thin, keep business rules testable and free of HTTP,
|
|
and keep all persistence in one place.
|
|
|
|
## Layer 1: API
|
|
|
|
Location:
|
|
|
|
- `src/routes/` — thin route wiring: `path → middleware → wrapAsync(controller)`.
|
|
- `src/api/controllers/` — one `<feature>.controller.ts` per feature; exported
|
|
async handler functions `(req, res) => …`.
|
|
- `src/api/http/` — request helpers (`wrapAsync`, `queryStr`, `queryNum`,
|
|
`paramStr`).
|
|
- `src/middlewares/` — `authenticate` (passport), `checkPermissions`,
|
|
`csrf-origin`, `error-handler`, `upload`.
|
|
|
|
Responsibilities:
|
|
|
|
- Parse and validate the HTTP request (query, params, body, cookies, uploads).
|
|
- Run middleware (auth, permissions, CSRF).
|
|
- Call exactly one BLL service and shape the HTTP response (status + body).
|
|
- Own multipart/upload parsing; pass parsed data (e.g. a file buffer) to the BLL.
|
|
|
|
The API layer must not:
|
|
|
|
- Import the DAL (`@/db/api/*`, `@/db/models/*`) — it goes through a service.
|
|
- Contain tenant/role/permission/workflow rules or DTO mapping.
|
|
- Run database queries.
|
|
|
|
### Authentication and public routes
|
|
|
|
Every `/api` route is JWT-authenticated at the mount (`authenticated = passport.authenticate('jwt', { session: false })`) **except** the intentionally public surface:
|
|
|
|
- the `/api/auth/*` public endpoints (sign-in / refresh / sign-out, password reset, email verification, OAuth — the authenticated sub-routes such as `/me` apply passport per route);
|
|
- `GET /api/public/campuses`;
|
|
- `GET /api/public/content-catalog/:contentType`.
|
|
|
|
No tenant-owned mutable data is exposed publicly. Authorization is then by permission: generic-CRUD routers apply `checkCrudPermissions(entity)` (`${METHOD}_${ENTITY}`); the feature routes apply `checkPermissions(<PRODUCT_FEATURE>)` for page reads and the special actions (`READ_FRAME`, `READ_WALKTHROUGH`, `READ_ATTENDANCE`, `READ_PARENT_COMM`, `READ_INTERNAL_COMM`, `FILL_ATTENDANCE`, `TAKE_QUIZ` — names from `shared/constants/product-permissions.ts`, so `custom_permissions` can extend access), while the manager-only writes (FRAME/walkthrough/communications/content-catalog editing and the staff/attendance reports) stay gated in their services by role until a dedicated `MANAGE_*` permission exists. The `users` / `staff` / `organizations` write paths add the §3.3 relational policy. Both `POST /api/file/upload` and `GET /api/file/download` require JWT, the local file handlers reject path traversal, and download enforces a per-file tenant/ownership check (the file's owning organization must match the requester's unless they have global access; see `file.md`).
|
|
|
|
## Layer 2: Business Logic (BLL)
|
|
|
|
Location:
|
|
|
|
- `src/services/` — one `<feature>.ts` (class with static methods) per feature,
|
|
plus per-feature mappers/validators/helpers as needed. Infra BLL lives in
|
|
`src/services/email/`.
|
|
|
|
Responsibilities:
|
|
|
|
- Own workflows, transactions, and coordination across repositories.
|
|
- Apply tenant, role, campus, and permission rules.
|
|
- Map DB records to response DTOs; validate and normalize inputs.
|
|
- Accept typed inputs and return typed values/DTOs.
|
|
|
|
The BLL must not:
|
|
|
|
- Touch Express `req`/`res` or import `express`/middleware. (Two legacy
|
|
exceptions remain — `services/file.ts` streaming and `services/auth.ts` session
|
|
IP/UA + cookies — tracked by the boundary test and to be revisited.)
|
|
- Import the API layer.
|
|
- Render HTTP responses.
|
|
|
|
## Layer 3: Data Access (DAL)
|
|
|
|
Location:
|
|
|
|
- `src/db/api/` — one `*DBApi` class per entity (the repository layer).
|
|
- `src/db/models/` — Sequelize models.
|
|
- `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`.
|
|
- `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`,
|
|
`CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`.
|
|
|
|
Responsibilities:
|
|
|
|
- Own all Sequelize queries and schema.
|
|
- Return records/plain data to the BLL.
|
|
|
|
The DAL must not:
|
|
|
|
- Import the API layer or the BLL. (One legacy exception: `db/api/file.ts`
|
|
imports `services/file` for GCloud blob deletion — tracked, to be inverted.)
|
|
- Apply business rules or touch HTTP.
|
|
|
|
## Cross-cutting
|
|
|
|
Location: `src/shared/` (+ ambient types in `src/types/`).
|
|
|
|
- `shared/constants/` — all constants/config values (was `src/constants`).
|
|
- `shared/config/` — env-driven runtime config (`index.ts` + `load-env.ts`).
|
|
- `shared/errors/` — `AppError` and subclasses.
|
|
- `shared/notifications/` — i18n message catalog + helpers.
|
|
- `shared/logger.ts`, `shared/csv.ts`, `shared/jwt.ts`.
|
|
- `shared/architecture/` — the import-boundary test.
|
|
|
|
Cross-cutting code depends on no layer and may be imported by any layer.
|
|
|
|
## Import direction
|
|
|
|
Allowed:
|
|
|
|
```text
|
|
Route → Controller → Service (BLL) → Repository/Model (DAL) → DB
|
|
```
|
|
|
|
`shared/*` may be imported by any layer. Disallowed:
|
|
|
|
```text
|
|
API (routes/controllers) → DAL (skip the BLL)
|
|
BLL (services) → Express / API
|
|
DAL (db) → BLL / API
|
|
shared/* → any layer
|
|
```
|
|
|
|
## Feature structure
|
|
|
|
Layer-first directories, one file per feature inside each layer (only create what
|
|
a feature needs):
|
|
|
|
```text
|
|
src/routes/<feature>.ts
|
|
src/api/controllers/<feature>.controller.ts
|
|
src/services/<feature>.ts (+ mappers/validators when needed)
|
|
src/db/api/<feature>.ts (repository)
|
|
src/db/models/<feature>.ts
|
|
src/shared/constants/<feature>.ts
|
|
```
|
|
|
|
## Module authoring (shared factories & helpers)
|
|
|
|
Most modules are assembled from shared factories/helpers — keep them that way.
|
|
|
|
- **Generic CRUD entity** = three one-line config files:
|
|
- `src/services/<e>.ts` → `export default createCrudService(EntityDBApi, { notFoundCode });`
|
|
- `src/api/controllers/<e>.controller.ts` → `export default createCrudController(service, { csvFields });`
|
|
- `src/routes/<e>.ts` → `export default createCrudRouter(controller, { permission });`
|
|
|
|
Factories: `services/shared/crud-service.ts`,
|
|
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts` (generic
|
|
over the repository's entity types — no casts). 18 of 21 entities use them;
|
|
entities with genuinely different behavior (`users` invitations,
|
|
`permissions` no-`globalAccess` queries) stay hand-written.
|
|
- **Repository (DAL)** = entity-specific `create`/`update`/`bulkImport`/`findBy`/
|
|
`findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to
|
|
`db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`,
|
|
`autocompleteByField`).
|
|
- **Feature service (BLL)** = reuse shared helpers: tenant/role access in
|
|
`services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`,
|
|
`hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`,
|
|
`campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …);
|
|
validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`,
|
|
`requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts`
|
|
(`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`;
|
|
`isRecord` from `shared/object.ts`.
|
|
- `getOrganizationIdOrGlobal(user)`: returns `null` for global access users
|
|
(bypassing org filter) or the user's org ID; throws `ForbiddenError` if neither.
|
|
- `hasGlobalAccess(user)`: returns `true` when `app_role.globalAccess === true`.
|
|
- `assertAuthenticatedTenantUser(user)`: allows global access users even without
|
|
an organization (useful for platform-level admins).
|
|
|
|
## Error handling
|
|
|
|
Centralized — see `backend/docs/error-handling.md`. Handlers/services throw an
|
|
`AppError` subclass; the terminal `error-handler` middleware turns it into the
|
|
`{ message, code?, details? }` JSON body the frontend `ApiError` consumes.
|
|
|
|
### Global error handlers
|
|
|
|
The server registers process-level handlers in `src/index.ts` to prevent crashes
|
|
from unhandled errors:
|
|
|
|
- `process.on('uncaughtException')` — catches synchronous errors
|
|
- `process.on('unhandledRejection')` — catches unhandled promise rejections
|
|
|
|
These log the error and allow the server to continue running. This protects
|
|
against crashes from misconfigured external services (e.g., SMTP without
|
|
credentials) or unexpected async failures.
|
|
|
|
### Production credential guards
|
|
|
|
Development defaults (DB credentials, SECRET_KEY) are hardcoded in
|
|
`shared/constants/app.ts` for local development convenience. However, these
|
|
defaults are **never applied in production-like environments**:
|
|
|
|
- `shared/config/index.ts`: `requiredEnvWithDevDefault()` throws if `SECRET_KEY`
|
|
is missing when `NODE_ENV` is `production` or `dev_stage`.
|
|
- `db/models/index.ts`: `validateProductionDbConfig()` throws if any `DB_*`
|
|
credential is missing in production-like environments.
|
|
|
|
This ensures the server fails fast with a clear error message rather than
|
|
silently using insecure defaults.
|
|
|
|
## Enforcement & verification
|
|
|
|
- `src/shared/architecture/import-boundaries.test.ts` enforces the import
|
|
direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL
|
|
edge cases and the one DAL→BLL leak are capped by ceilings that must not grow.
|
|
- ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the
|
|
already-clean invariants at lint time (API→DAL, model/DAL/shared purity).
|
|
- `npm run typecheck`, `npm run lint`, `npm test` are the verification gates;
|
|
`npm test` runs the Node test runner via `tsx` (error-handler + boundary tests).
|
|
|
|
## Known remaining items
|
|
|
|
- `services/file.ts` and `services/auth.ts` still depend on `req`/`res` (file
|
|
streaming; session IP/UA + cookies). To be revisited with the upload subsystem.
|
|
- `db/api/file.ts` → `services/file` (GCloud delete) is a DAL→BLL leak to invert
|
|
(the BLL should orchestrate blob + row deletion).
|
|
- `src/index.ts` remains the composition root + entry; an `app/server.ts` split is
|
|
optional and deferred (deploy runs `dist/index.js`).
|
|
- Repositories still hand-roll the `findAll` filter→`where` building per entity; a
|
|
declarative where-builder could dedup it, deferred until the data platform
|
|
stabilizes (higher-risk Sequelize typing).
|