Skip to content

Backend overview

Source: g2labs-in/billuu-docs@9f1dcd7 · content/backend/overview.md · updated 2026-09-16

What the backend is

brewlabs-billu-backend is an Express 4 API over Sequelize 6 and PostgreSQL. It serves the POS, the admin console, the kitchen display, and the customer QR ordering flow.

Data is separated by PostgreSQL schema rather than by a tenant column. Every tenant gets its own schema named tenant_<businessId>. Platform tables such as users, tenants, organizations, and plans live in the public schema, which the code calls the control plane.

Runtime stack, from package.json:

ConcernChoice
HTTPexpress 4
ORMsequelize 6, pg
Jobsbullmq with ioredis, node-cron
Real timesocket.io
Authjsonwebtoken, bcrypt, passport-google-oauth20
Files and paymentscloudinary, razorpay, @aws-sdk/client-s3
Testsjest, supertest

The entry point

app.js at the repo root is the entry point. It creates the Express app, builds the HTTP or HTTPS server, and calls the startup functions at the bottom of the file.

package.json declares "main": "src/app.js". That file does not exist. The main field is unused here, because npm start runs node app.js directly.

CommandWhat it runs
npm startnode app.js
npm run devnodemon --inspect=0.0.0.0:9229 --exec 'npm run start'
npm run start:parallelnode src/scripts/start-parallel.js, which checks DATABASE_URL, ENCRYPTION_KEY, and JWT_SECRET, then spawns npm run dev
npm run dev:parallelnodemon --inspect=0.0.0.0:9229 --exec 'node --max-old-space-size=4096 app.js'

src/index.js and src/server.js both exist and are zero bytes. Nothing requires them.

Startup order

app.js runs top to bottom, then starts three functions. The order matters because routes are registered while the module loads.

StepWhat happensWhere
1dns.setDefaultResultOrder('ipv4first')app.js:5
2dotenv loads .env, then src/config/redis.js loads early to resolve the Redis hostnameapp.js:10-11
3NODE_ENV defaults to production when unsetapp.js:12
4Environment verification logs DEFAULT_DB_HOST and whether DATABASE_URL is set, then resolves a fixed Supabase hostname with dns.lookupapp.js:18-29
5process.on('unhandledRejection') and process.on('uncaughtException') log and swallow, so the process survivesapp.js:65-86
6BullMQ workers are required only when Redis is not the mock, to avoid an endless reconnect loopapp.js:55-62
7const app = express()app.js:89
8HTTPS server when certs/localhost+2-key.pem and certs/localhost+2.pem exist, otherwise HTTP. The repo ships certs_backup/, not certs/, so a fresh checkout runs HTTPapp.js:91-111
9All middleware and every route mount are registeredapp.js:120-410
10startMultiTenantServer(), startPeriodicTasks(), startBackgroundJobs()app.js:878-880

startMultiTenantServer at app.js:634 does the real work:

  1. initializeMultiTenantApp() calls initializeMultiTenantDatabases().
  2. connectUnifiedDB() from src/config/unified_database.js opens the pool.
  3. ModelFactory.createModels(sequelize) builds every model, and startup throws if sequelize.models.User is missing.
  4. bootstrapControlPlane() from src/architecture/bootstrapControlPlane.js creates missing platform tables and seeds them. Set SKIP_STARTUP_MIGRATIONS=true to skip this and the next two steps.
  5. initialize() from src/architecture/dataFirstInitializer.js runs with strictMode: process.env.STRICT_SCHEMA_MODE === 'true'.
  6. runStartupMigrations() from src/architecture/startupMigration.js applies pending migrations to active tenants.
  7. runStartupValidation() from src/architecture/startupValidator.js audits tenant schemas for drift, then healSchema() from src/startup/schemaValidation.js repairs missing columns.
  8. verifyConnections() pings the database and Redis and only logs the result.
  9. socketService.init(server) and initInventoryEvents() from src/services/inventoryEventListeners.js.
  10. server.listen(PORT) with PORT defaulting to 8000.

If initialization throws, production calls process.exit(1). Any other environment warns and starts anyway, and database-backed endpoints return 503.

Folder layout under src/

Counts are .js files at the time of writing.

PathFilesWhat lives there
src/architecture/39Model loading and schema machinery. modelFactory.js, tenantModelLoader.js, centralAssociations.js, migrationRunner.js, databaseMiddlewareChain.js, bootstrapControlPlane.js
src/core/32Cross-cutting runtime. src/core/database/databaseExecutor.js, src/core/tenant/, src/core/auth/, src/core/rbac/, src/core/middleware/, src/core/diagnostics/, src/core/cache/
src/models/71Tenant-schema model definitions, one file per model
src/control_plane_models/16public schema models, plus index.js which exposes them
src/controllers/68HTTP handlers. Tenant handlers sit in controllers/tenant/
src/services/58Business logic. inventoryService.js, productAvailabilityEngine.js, onboardingService.js, schemaCreationService.js
src/routes/20Routers. tenantRoutes.js is the main one
src/middlewares/34HTTP middleware. safeControllerWrapper.js, associationInitializer.js, observability.js
src/migrations/45Two runners plus migrations/tenant/, which holds 37 .js and 6 .sql migration files
src/utils/49constants.js, logger.js, standardResponse.js, tenantScope.js, entityScopeRegistry.js
src/scripts/89CLI scripts wired to npm run targets
src/tests/51All Jest tests. There is no tests/ directory at the repo root
src/modules/32Feature modules, for example src/modules/franchises/franchiseRoutes.js
src/workers/4BullMQ workers and fefoExpiryAlertCron.js
src/config/14unified_database.js, control_plane_db.js, redis.js, corsConfig.js, orderStatuses.js
src/auth/4auth.controller.js, auth.routes.js, google.service.js, otp.service.js
src/infrastructure/websocket/1socketService.js
src/startup/1schemaValidation.js
src/repositories/, src/domains/, src/db/, src/security/, src/cache/, src/constants/18Smaller layers. src/constants/permissions.js is the main export here

One dead path to know about. src/services/databaseExecutor.js is a deprecated re-export of src/core/database/databaseExecutor.js. New code imports the core path.

Request lifecycle

A request goes through app-level middleware, then a per-mount chain, then the router, the controller, and finally the database executor. Here is POST /api/tenant/orders traced end to end.

StageFileDetail
App middlewareapp.js:120-238CORS, cookie-parser, helmet, compression, body parsers, response envelope, profiler, request timeout
Mountsrc/architecture/databaseMiddlewareChain.jsapp.use('/api/tenant', ...tenantChain, tenantRoutes)
Chainsame filerequestTimingMiddleware, requestTimeoutMiddleware(30000), standardResponseMiddleware, UnifiedAuthMiddleware.authenticate, UnifiedTenantMiddleware.setupTenantContext, modelsSafetyLayer
Authsrc/core/auth/unifiedAuthMiddleware.jsVerifies the JWT, loads the user from public, attaches req.user and req.auth
Tenant contextsrc/core/tenant/unifiedTenantMiddleware.jsResolves business, schema, organization, and outlet, then attaches req.executeWithTenant, req.readWithTenant, req.readWithCache, and req.models at lines 451-482
Route matchsrc/routes/tenantRoutes.js:211router.post('/orders', requirePermission('CREATE_ORDER'), orderController.addOrder)
Permissionsrc/core/rbac/rbacMiddleware.jsrequirePermission checks the role and permission tables
Controllersrc/controllers/tenant/orderController.jsaddOrder builds the order, calls req.executeWithTenant(...) at line 208
Servicesrc/services/inventoryService, productAvailabilityEngine, availabilityValidationService, customerDueService
Databasesrc/core/database/databaseExecutor.jsexecuteWithTenant opens a transaction, binds models to the tenant schema, runs the callback, commits
Responsesrc/utils/standardResponse.jsresponseValidationMiddleware rewrites res.json output into the standard envelope

Routes are declared in a router file, and every handler is wrapped by wrapController from src/middlewares/safeControllerWrapper.js, which converts a thrown error into next(error). src/routes/tenantRoutes.js wraps each controller through a local loadController helper that substitutes a stub controller when a require fails, so a broken import shows up as a stub response with a warning in the log rather than a crash.

Middleware chain

App-level middleware, in registration order, from app.js:

OrderMiddlewarePurpose
1CORS debug loggerPrints origin and preflight headers when DEBUG_CORS=true
2CORS handlerReflects the requesting origin when allowed, answers OPTIONS with 204
3cookieParser()Reads cookies, needed for refresh tokens
4helmet()Security headers. crossOriginResourcePolicy is off, and the CSP allows inline styles and Supabase connections
5compression()Response compression
6express.json({ limit: '10mb' }), express.urlencoded(...)Body parsing
7standardResponseMiddlewareAttaches res.sendSuccess and res.sendError
8responseValidationMiddlewareRewrites every res.json payload into { success, message, data }
9profilerMiddlewareOpens an AsyncLocalStorage store per request for timing, memory, and executor passes
10Request timeoutAnswers 408 after REQUEST_TIMEOUT_MS, default 60000
11systemMetrics.middleware()Counts requests and responses for /api/system/metrics

Route-specific middleware comes next, from src/architecture/databaseMiddlewareChain.js:

MountChain
/api/adminadminChain: timeout, standard response, authenticate, adminOnly
/api/tenant, /api/inventory, /api/reportstenantChain: request timing, timeout, standard response, authenticate, setupTenantContext, modelsSafetyLayer
/api/auth, /api/sms, /api/user, /api/upload, /api/menu, /api/qr, /api/sessions, /api/customer, /api/onboarding, /api/super-admin, /apipublicChain: request timing, cookie-parser, timeout, standard response

Two details worth remembering. standardResponseMiddleware is applied twice on tenant routes, once in app.js and once inside the chain, which is harmless because it only assigns two methods. And /api/tenant is mounted twice, once with src/routes/tenantRoutes.js and once with src/modules/franchises/franchiseRoutes.js, which covers sub-outlet paths such as /sub-outlets and /link-outlet.

Tenant isolation

Isolation comes from schema-bound model copies, not from session state.

src/core/database/databaseExecutor.js is the only supported route to the database. It exposes a small API.

MethodUse
executeWithTenant(tenantId, fn, options)Write work inside a transaction, in the tenant schema
readWithTenant(tenantId, fn, options)Read work, no transaction
readWithCache(tenantId, key, fn, options)Read through Redis and an in-flight request deduplicator
executeInPublic(fn, options)Write work in the public schema
readInPublic(fn)Read work in public
getTenantModels(tenantId, options)The bound model set for a tenant, cached
healthCheck()Reports connection health

Pass the CONTROL_PLANE constant from src/utils/constants.js as the tenant id to reach public through the tenant methods. executeWithTenant rejects 'public', 'health_check', and tenant_public* outright, so control plane writes must go through executeInPublic or CONTROL_PLANE.

Inside executeWithTenant the executor:

  1. Resolves the schema name as tenant_<tenantId>, or public for CONTROL_PLANE.
  2. Gets bound models from ModelFactory.getBoundModels(schemaName), which calls model.schema(schemaName) on each model flagged _requiresSchemaBinding.
  3. Opens a transaction unless the caller passed one, so nested executor calls join the outer transaction.
  4. Wraps the model set in a Proxy that injects outletId into where for models that have an outlet column, when options.outletId is set and the tenant is not the control plane.
  5. Passes { transaction, models, transactionModels, schemaName, sequelize } to the callback.
  6. Commits, or rolls back on error. A 25P02 aborted-transaction error destroys the pooled connection instead of only rolling back, to stop the failure from spreading to later requests.

The file states plainly that tenant isolation does not use SET search_path, because that leaks across pooled connections. Migrations are the exception, and they do set the path with SET LOCAL inside their own transaction.

Error handling

There are three layers.

The app-level handler at app.js:411 is the last middleware. It maps known Sequelize and JWT error names to status codes.

ErrorStatusResponse
SequelizeConnectionError, SequelizeConnectionRefusedError503{ success: false, message }
SequelizeTimeoutError408{ success: false, message }
JsonWebTokenError, TokenExpiredError401{ success: false, message }
Message contains transaction500{ success: false, message }
Anything elseerror.status, error.statusCode, or 500{ success: false, message }

The message is the real one only when NODE_ENV is development. Otherwise it is replaced with a fixed string. The handler never crashes, even when logging or res.json throws.

wrapController in src/middlewares/safeControllerWrapper.js wraps every route handler and forwards a rejected promise to next, so a controller can throw without killing the request.

An unmatched route hits the catch-all at app.js:390, which answers 404 with { error, message, availableRoutes, multiTenant }. That shape differs from the standard envelope, as does the timeout response at app.js:213, which adds an error key next to success.

src/core/middleware/globalErrorHandlers.js exports globalErrorHandler, globalAsyncHandler, and requestTimeoutMiddleware. app.js:46 requires globalErrorHandler but never registers it. The inline handler at app.js:411 is the one that runs.

Background and scheduled work

startBackgroundJobs at app.js:736 registers three recurring jobs.

ScheduleJobBehavior
Every 15 minutes, plus once 30 seconds after bootMetrics refreshRuns node scripts/update_system_metrics.js through exec with a 60 second timeout, and skips a tick while the previous one is still running
Every 5 minutesPending payment expiryLoads active tenants from TenantRegistry, finds orders in PAYMENT_PENDING or PAYMENT_VERIFICATION_PENDING older than 15 minutes, sets Order.status to REJECTED and Payment.status to FAILED, then emits PAYMENT_REJECTED over Socket.IO to the guest session
Daily at 06:00, override with FEFO_CRON_SCHEDULEFEFO expiry alertsstartFefoExpiryAlertCron() from src/workers/fefoExpiryAlertCron.js scans each active tenant schema, marks batches within 48 hours of expiry as LOW_EXPIRY and past-expiry batches as EXPIRED, and emits BATCH_LOW_EXPIRY or BATCH_EXPIRED

BullMQ workers start at require time, and only when Redis is real.

WorkerQueueNotes
src/workers/notificationWorker.jspush-notificationsHandles sendNewOrderPush, concurrency 5
src/workers/onboardingWorker.jsOnboarding jobsRequired at app.js:58

src/workers/snapshotWorker.js exists and is not required from app.js. startPeriodicTasks at app.js:729 is a stub that logs a message, because the old transaction cleanup loop was removed for causing 60 to 90 second response delays.

Shutdown is handled by SIGTERM and SIGINT listeners at app.js:861-875. Both close the HTTP server and exit 0.

Health and diagnostics

EndpointReturns
/healthDatabase and Redis status, uptime, environment, version, and whether trust proxy is on
/health/detailedModel counts from src/services/unifiedModelManager.js, databaseExecutor.healthCheck(), and process.memoryUsage()
/api/system/metricsSnapshot from src/core/diagnostics/systemMetrics.js, guarded by the x-internal-secret header against INTERNAL_METRICS_SECRET, or by an ADMIN or SUPER_ADMIN bearer token

app.set('trust proxy', ...) is 2 in production for Cloudflare and Traefik, and 1 otherwise. Every startup step logs a line with an emoji prefix, and LOG_ROUTES=true prints the route table at listen time.

Sources

  • brewlabs-billu-backend/app.js
  • brewlabs-billu-backend/package.json
  • brewlabs-billu-backend/src/architecture/databaseMiddlewareChain.js
  • brewlabs-billu-backend/src/architecture/modelFactory.js
  • brewlabs-billu-backend/src/architecture/centralAssociations.js
  • brewlabs-billu-backend/src/architecture/migrationRunner.js
  • brewlabs-billu-backend/src/architecture/startupMigration.js
  • brewlabs-billu-backend/src/core/database/databaseExecutor.js
  • brewlabs-billu-backend/src/core/tenant/unifiedTenantMiddleware.js
  • brewlabs-billu-backend/src/core/auth/unifiedAuthMiddleware.js
  • brewlabs-billu-backend/src/core/rbac/rbacMiddleware.js
  • brewlabs-billu-backend/src/core/middleware/globalErrorHandlers.js
  • brewlabs-billu-backend/src/core/middleware/requestProfiler.js
  • brewlabs-billu-backend/src/middlewares/safeControllerWrapper.js
  • brewlabs-billu-backend/src/middlewares/associationInitializer.js
  • brewlabs-billu-backend/src/routes/tenantRoutes.js
  • brewlabs-billu-backend/src/routes/permissionRoutes.js
  • brewlabs-billu-backend/src/controllers/tenant/orderController.js
  • brewlabs-billu-backend/src/controllers/menuPermissionController.js
  • brewlabs-billu-backend/src/services/databaseExecutor.js
  • brewlabs-billu-backend/src/core/database/index.js
  • brewlabs-billu-backend/src/modules/franchises/franchiseRoutes.js
  • brewlabs-billu-backend/src/utils/standardResponse.js
  • brewlabs-billu-backend/src/utils/constants.js
  • brewlabs-billu-backend/src/utils/entityScopeRegistry.js
  • brewlabs-billu-backend/src/utils/tenantScope.js
  • brewlabs-billu-backend/src/config/orderStatuses.js
  • brewlabs-billu-backend/src/workers/notificationWorker.js
  • brewlabs-billu-backend/src/workers/fefoExpiryAlertCron.js
  • brewlabs-billu-backend/src/tests/setup.js
  • brewlabs-billu-backend/docs/LOCAL_SETUP.md