Naalya Handbook

Architecture

The monorepo's three apps, the shared library, and the exact path a request takes through the server.

The Naalya backend is a NestJS monorepo: several apps in one repository, sharing one set of dependencies and one pile of common code. The split exists because not all work is request work — answering HTTP should be fast, while email, audit writes, and external syncs are slow and retryable, so they run in separate processes.

Three things to hold in your head:

  • server — the HTTP API that answers the frontend.
  • worker and audit — background processes, no HTTP, just jobs off a queue.
  • libs/shared (imported as @app/shared) — code all three reuse.

Default project is server

Projects are declared in nest-cli.json; the default is server, so pnpm start:dev with no arguments runs the API. The worker and audit apps have their own scripts. If a command "does nothing," check which project it targeted.

Repo layout

nest-cli.json (layout)
apps/
  server/   → the HTTP API
  worker/   → background job processor
  audit/    → audit-log microservice
libs/
  shared/   → code shared across all apps (@app/shared)
database/
  migrations/   → TypeORM migrations (root-level, app-agnostic)
emails/         → React Email templates

Each app is self-contained, with its own main.ts and root module. Apps never import each other — the only shared code comes through @app/shared.

What the apps talk to

Four backing services, and in development only two of them are on your machine — see Getting Started for the split.

ServiceRoleReached through
PostgresEvery domain row. Hosted on Neon.TypeORM, BaseRepository
RabbitMQQueues, and the agent-tool RPC seam. Hosted on CloudAMQP.BullMQ, Nest microservice clients
RedisBullMQ backing store, and Centrifugo's engine.REDIS_URL
CentrifugoWebsocket broker for realtime.RealtimeService

There is also a type bus: the server publishes shared TypeScript types at /.well-known/chowbea.json, which the Education Hub generates from. Types that never appear on a REST body — websocket payloads, domain enums — cross there instead of through Swagger. See How Chowbea works.

The three apps

AppBoot fileListens onHosts
serverapps/server/src/main.tsHTTP port 8000, prefix api/v1~48 feature modules
workerapps/worker/src/main.tsnaalya-worker RMQ queueEmail, PDF, KB-embedding and payment-charge processors — plus the Rover agent graph
auditapps/audit/src/main.tsnaalya-audit RMQ queueAuditLogProcessor

server — Express 5 HTTP server. Its root module apps/server/src/app/app.module.ts wires the database, the Redis/BullMQ connection, and one feature module per domain. You add a feature module and register it here; you rarely edit app.module.ts directly. Every route sits under api/v1 (so a users route is /api/v1/users) — the lone exception is /queues*, excluded so the Bull Board dashboard stays mounted at /queues.

worker is more than a queue consumer. Alongside its BullMQ processors it hosts naalya-agent/ — the LangGraph graph behind Rover and the role subagents — which reaches school data by calling back into the server over RabbitMQ rather than touching the database itself. That seam is the whole security model: How the AI system works.

audit is the simple one: a NestJS app with no HTTP server consuming one queue. It runs in its own process for isolation, so writing audit records never slows an API request. How the queues work → Queues & Messaging.

libs/shared

@app/shared is exposed through one barrel file (libs/shared/src/index.ts). Import from the package name, never a deep path:

users.service.ts (shape)
import { BaseRepository, UserType, generateSlug } from '@app/shared';
GroupWhat you'll find
DatabaseDatabaseEntity, BaseRepository, DatabaseModule, cursor-pagination helpers
Configconfig, validateConfig, env types
API helpersMessageResponseDto, ErrorResponseDto, Swagger error decorators
EnumsUserType, ApplicationStatus, ScopeLevel, DepartmentRole, …
Queue contractsqueue names, job-payload types, microservice tokens

Permissions live on a subpath

The CASL Action / Resource enums and PermissionScopeModule come from @app/shared/permission, not the top-level barrel. An Action import that "can't be found" is usually pointed at @app/shared.

Path aliases

Defined in tsconfig.json (and mirrored in the Vitest configs). Use them before reaching for a relative ../ chain.

tsconfig.json
"paths": {
  "@app/shared": ["libs/shared/src"],
  "@app/shared/*": ["libs/shared/src/*"],
  "@server/*": ["apps/server/src/*"],
  "@worker/*": ["apps/worker/src/*"]
}

Rule of thumb: @app/shared for anything reused across apps, @server/* for paths inside the server app.

Request lifecycle

A request to the server app passes through a fixed assembly line before reaching your handler. Order is set in main.ts (middleware, pipe, interceptor, filter) and auth.module.ts (guards).

request flow (server)
HTTP request
  ▼  Express middleware  (permissionScope → tenantContext → requestId → cookieParser)
  ▼  Global guards       (JwtAuthGuard → UserTypeGuard → SystemRoleGuard → PermissionsGuard)
  ▼  ValidationPipe      (validate + transform the DTO)
  ▼  Controller handler  (your code)
  ▼  LoggingInterceptor  (wraps the response)
  ▼  Exception filters   (only if something threw)
  ▼  HTTP response
StageJob
MiddlewarePlain Express, runs before Nest. tenantContextMiddleware resolves the active school into AsyncLocalStorage so BaseRepository auto-scopes by schoolId — see Multi-tenancy.
GuardsFour APP_GUARDs, ordered cheapest-check-first: logged in → user type → system role → CASL permission. Short-circuit on first failure. Details → Auth & Permissions.
ValidationPipeRuns the controller DTO's class-validator rules; bad input becomes a clean 400, never a crash.
HandlerYour controller method. The only stage you usually write.
LoggingInterceptorWraps the response, logging it with the request ID.
Exception filtersAllExceptionsFilter formats throws into the standard error shape; SentryGlobalFilter reports them. Run only on failure.

Guard order is load-bearing

Authentication (JwtAuthGuard) runs before authorization (PermissionsGuard) and the chain stops on the first false. Don't assume a later guard ran.

Where to go next

On this page