40227-vm/backend/docs/backend-architecture.md

11 KiB

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.
  • src/commands/ — CLI/maintenance entrypoints. Commands are API-layer adapters: parse/run the operation, call BLL services, and own process output.

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.

No tenant-owned mutable data is exposed publicly. Content catalog reads now use authenticated GET /api/content-catalog/read/:contentType so scoped content can resolve from the current user. Authorization is then by effective permission: generic-CRUD routers apply checkCrudPermissions(entity) (${METHOD}_${ENTITY}); the feature routes apply checkPermissions(<PRODUCT_FEATURE>) for page reads and 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). Service-level feature gates use dedicated permissions such as MANAGE_FRAME, MANAGE_WALKTHROUGH, MANAGE_CONTENT_CATALOG, and report-read permissions. globalAccess expands tenant reach to platform scope; only super_admin bypasses the standard management/page permission checks, and even that bypass excludes personal workflow permissions (READ_PARENT_COMM, ACK_POLICY, ZONE_CHECKIN), which still require explicit grants. The User Admin custom_permissions / custom_permissions_filter controls can therefore add or remove these feature grants for tenant users, including system_admin. The users / organizations write paths still add the §3.3 relational policy because hierarchy constraints cannot be expressed as flat permissions. Both POST /api/file/upload and GET /api/file/download require JWT, and the local file handlers reject path traversal; downloads are JWT-only after the customer decision to remove per-file ownership checks (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, scope, 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/reset.ts, src/db/umzug.ts, and other DB operational helpers.
  • 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:

Route → Controller → Service (BLL) → Repository/Model (DAL) → DB

shared/* may be imported by any layer. Disallowed:

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):

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>.tsexport default createCrudService(EntityDBApi, { notFoundCode });
    • src/api/controllers/<e>.controller.tsexport default createCrudController(service, { csvFields });
    • src/routes/<e>.tsexport 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/scope access in services/shared/access.ts (getOrganizationId, getOrganizationIdOrGlobal, hasGlobalAccess, requireUserId, hasFeaturePermission, scopeDimensionWhere, 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. Every production .ts file must be assigned to a layer (test files, declarations, and test-utils/ are excluded). The test resolves alias and relative project imports, treats all src/db/** files as DAL, forbids unapproved cross-layer edges, and verifies exact allowlists so stale exceptions are removed instead of silently accumulating.
  • Current exact architecture exceptions are:
    • auth/auth.ts -> @/db/api/users and middlewares/check-permissions.ts -> @/db/api/roles for API edge wiring.
    • services/auth.ts -> express, services/file.ts -> express, and services/file.ts -> @/middlewares/upload for remaining HTTP-in-BLL cases.
    • db/api/file.ts -> @/services/file for the file-storage deletion bridge.
  • 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.tsservices/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).