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:
| Concern | Choice |
|---|---|
| HTTP | express 4 |
| ORM | sequelize 6, pg |
| Jobs | bullmq with ioredis, node-cron |
| Real time | socket.io |
| Auth | jsonwebtoken, bcrypt, passport-google-oauth20 |
| Files and payments | cloudinary, razorpay, @aws-sdk/client-s3 |
| Tests | jest, 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.
| Command | What it runs |
|---|---|
npm start | node app.js |
npm run dev | nodemon --inspect=0.0.0.0:9229 --exec 'npm run start' |
npm run start:parallel | node src/scripts/start-parallel.js, which checks DATABASE_URL, ENCRYPTION_KEY, and JWT_SECRET, then spawns npm run dev |
npm run dev:parallel | nodemon --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.
| Step | What happens | Where |
|---|---|---|
| 1 | dns.setDefaultResultOrder('ipv4first') | app.js:5 |
| 2 | dotenv loads .env, then src/config/redis.js loads early to resolve the Redis hostname | app.js:10-11 |
| 3 | NODE_ENV defaults to production when unset | app.js:12 |
| 4 | Environment verification logs DEFAULT_DB_HOST and whether DATABASE_URL is set, then resolves a fixed Supabase hostname with dns.lookup | app.js:18-29 |
| 5 | process.on('unhandledRejection') and process.on('uncaughtException') log and swallow, so the process survives | app.js:65-86 |
| 6 | BullMQ workers are required only when Redis is not the mock, to avoid an endless reconnect loop | app.js:55-62 |
| 7 | const app = express() | app.js:89 |
| 8 | HTTPS 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 HTTP | app.js:91-111 |
| 9 | All middleware and every route mount are registered | app.js:120-410 |
| 10 | startMultiTenantServer(), startPeriodicTasks(), startBackgroundJobs() | app.js:878-880 |
startMultiTenantServer at app.js:634 does the real work:
initializeMultiTenantApp()callsinitializeMultiTenantDatabases().connectUnifiedDB()fromsrc/config/unified_database.jsopens the pool.ModelFactory.createModels(sequelize)builds every model, and startup throws ifsequelize.models.Useris missing.bootstrapControlPlane()fromsrc/architecture/bootstrapControlPlane.jscreates missing platform tables and seeds them. SetSKIP_STARTUP_MIGRATIONS=trueto skip this and the next two steps.initialize()fromsrc/architecture/dataFirstInitializer.jsruns withstrictMode: process.env.STRICT_SCHEMA_MODE === 'true'.runStartupMigrations()fromsrc/architecture/startupMigration.jsapplies pending migrations to active tenants.runStartupValidation()fromsrc/architecture/startupValidator.jsaudits tenant schemas for drift, thenhealSchema()fromsrc/startup/schemaValidation.jsrepairs missing columns.verifyConnections()pings the database and Redis and only logs the result.socketService.init(server)andinitInventoryEvents()fromsrc/services/inventoryEventListeners.js.server.listen(PORT)withPORTdefaulting 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.
| Path | Files | What lives there |
|---|---|---|
src/architecture/ | 39 | Model loading and schema machinery. modelFactory.js, tenantModelLoader.js, centralAssociations.js, migrationRunner.js, databaseMiddlewareChain.js, bootstrapControlPlane.js |
src/core/ | 32 | Cross-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/ | 71 | Tenant-schema model definitions, one file per model |
src/control_plane_models/ | 16 | public schema models, plus index.js which exposes them |
src/controllers/ | 68 | HTTP handlers. Tenant handlers sit in controllers/tenant/ |
src/services/ | 58 | Business logic. inventoryService.js, productAvailabilityEngine.js, onboardingService.js, schemaCreationService.js |
src/routes/ | 20 | Routers. tenantRoutes.js is the main one |
src/middlewares/ | 34 | HTTP middleware. safeControllerWrapper.js, associationInitializer.js, observability.js |
src/migrations/ | 45 | Two runners plus migrations/tenant/, which holds 37 .js and 6 .sql migration files |
src/utils/ | 49 | constants.js, logger.js, standardResponse.js, tenantScope.js, entityScopeRegistry.js |
src/scripts/ | 89 | CLI scripts wired to npm run targets |
src/tests/ | 51 | All Jest tests. There is no tests/ directory at the repo root |
src/modules/ | 32 | Feature modules, for example src/modules/franchises/franchiseRoutes.js |
src/workers/ | 4 | BullMQ workers and fefoExpiryAlertCron.js |
src/config/ | 14 | unified_database.js, control_plane_db.js, redis.js, corsConfig.js, orderStatuses.js |
src/auth/ | 4 | auth.controller.js, auth.routes.js, google.service.js, otp.service.js |
src/infrastructure/websocket/ | 1 | socketService.js |
src/startup/ | 1 | schemaValidation.js |
src/repositories/, src/domains/, src/db/, src/security/, src/cache/, src/constants/ | 18 | Smaller 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.
| Stage | File | Detail |
|---|---|---|
| App middleware | app.js:120-238 | CORS, cookie-parser, helmet, compression, body parsers, response envelope, profiler, request timeout |
| Mount | src/architecture/databaseMiddlewareChain.js | app.use('/api/tenant', ...tenantChain, tenantRoutes) |
| Chain | same file | requestTimingMiddleware, requestTimeoutMiddleware(30000), standardResponseMiddleware, UnifiedAuthMiddleware.authenticate, UnifiedTenantMiddleware.setupTenantContext, modelsSafetyLayer |
| Auth | src/core/auth/unifiedAuthMiddleware.js | Verifies the JWT, loads the user from public, attaches req.user and req.auth |
| Tenant context | src/core/tenant/unifiedTenantMiddleware.js | Resolves business, schema, organization, and outlet, then attaches req.executeWithTenant, req.readWithTenant, req.readWithCache, and req.models at lines 451-482 |
| Route match | src/routes/tenantRoutes.js:211 | router.post('/orders', requirePermission('CREATE_ORDER'), orderController.addOrder) |
| Permission | src/core/rbac/rbacMiddleware.js | requirePermission checks the role and permission tables |
| Controller | src/controllers/tenant/orderController.js | addOrder builds the order, calls req.executeWithTenant(...) at line 208 |
| Service | src/services/ | inventoryService, productAvailabilityEngine, availabilityValidationService, customerDueService |
| Database | src/core/database/databaseExecutor.js | executeWithTenant opens a transaction, binds models to the tenant schema, runs the callback, commits |
| Response | src/utils/standardResponse.js | responseValidationMiddleware 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:
| Order | Middleware | Purpose |
|---|---|---|
| 1 | CORS debug logger | Prints origin and preflight headers when DEBUG_CORS=true |
| 2 | CORS handler | Reflects the requesting origin when allowed, answers OPTIONS with 204 |
| 3 | cookieParser() | Reads cookies, needed for refresh tokens |
| 4 | helmet() | Security headers. crossOriginResourcePolicy is off, and the CSP allows inline styles and Supabase connections |
| 5 | compression() | Response compression |
| 6 | express.json({ limit: '10mb' }), express.urlencoded(...) | Body parsing |
| 7 | standardResponseMiddleware | Attaches res.sendSuccess and res.sendError |
| 8 | responseValidationMiddleware | Rewrites every res.json payload into { success, message, data } |
| 9 | profilerMiddleware | Opens an AsyncLocalStorage store per request for timing, memory, and executor passes |
| 10 | Request timeout | Answers 408 after REQUEST_TIMEOUT_MS, default 60000 |
| 11 | systemMetrics.middleware() | Counts requests and responses for /api/system/metrics |
Route-specific middleware comes next, from src/architecture/databaseMiddlewareChain.js:
| Mount | Chain |
|---|---|
/api/admin | adminChain: timeout, standard response, authenticate, adminOnly |
/api/tenant, /api/inventory, /api/reports | tenantChain: 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, /api | publicChain: 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.
| Method | Use |
|---|---|
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:
- Resolves the schema name as
tenant_<tenantId>, orpublicforCONTROL_PLANE. - Gets bound models from
ModelFactory.getBoundModels(schemaName), which callsmodel.schema(schemaName)on each model flagged_requiresSchemaBinding. - Opens a transaction unless the caller passed one, so nested executor calls join the outer transaction.
- Wraps the model set in a
Proxythat injectsoutletIdintowherefor models that have an outlet column, whenoptions.outletIdis set and the tenant is not the control plane. - Passes
{ transaction, models, transactionModels, schemaName, sequelize }to the callback. - Commits, or rolls back on error. A
25P02aborted-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.
| Error | Status | Response |
|---|---|---|
SequelizeConnectionError, SequelizeConnectionRefusedError | 503 | { success: false, message } |
SequelizeTimeoutError | 408 | { success: false, message } |
JsonWebTokenError, TokenExpiredError | 401 | { success: false, message } |
Message contains transaction | 500 | { success: false, message } |
| Anything else | error.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.
| Schedule | Job | Behavior |
|---|---|---|
| Every 15 minutes, plus once 30 seconds after boot | Metrics refresh | Runs 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 minutes | Pending payment expiry | Loads 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_SCHEDULE | FEFO expiry alerts | startFefoExpiryAlertCron() 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.
| Worker | Queue | Notes |
|---|---|---|
src/workers/notificationWorker.js | push-notifications | Handles sendNewOrderPush, concurrency 5 |
src/workers/onboardingWorker.js | Onboarding jobs | Required 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
| Endpoint | Returns |
|---|---|
/health | Database and Redis status, uptime, environment, version, and whether trust proxy is on |
/health/detailed | Model counts from src/services/unifiedModelManager.js, databaseExecutor.healthCheck(), and process.memoryUsage() |
/api/system/metrics | Snapshot 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.jsbrewlabs-billu-backend/package.jsonbrewlabs-billu-backend/src/architecture/databaseMiddlewareChain.jsbrewlabs-billu-backend/src/architecture/modelFactory.jsbrewlabs-billu-backend/src/architecture/centralAssociations.jsbrewlabs-billu-backend/src/architecture/migrationRunner.jsbrewlabs-billu-backend/src/architecture/startupMigration.jsbrewlabs-billu-backend/src/core/database/databaseExecutor.jsbrewlabs-billu-backend/src/core/tenant/unifiedTenantMiddleware.jsbrewlabs-billu-backend/src/core/auth/unifiedAuthMiddleware.jsbrewlabs-billu-backend/src/core/rbac/rbacMiddleware.jsbrewlabs-billu-backend/src/core/middleware/globalErrorHandlers.jsbrewlabs-billu-backend/src/core/middleware/requestProfiler.jsbrewlabs-billu-backend/src/middlewares/safeControllerWrapper.jsbrewlabs-billu-backend/src/middlewares/associationInitializer.jsbrewlabs-billu-backend/src/routes/tenantRoutes.jsbrewlabs-billu-backend/src/routes/permissionRoutes.jsbrewlabs-billu-backend/src/controllers/tenant/orderController.jsbrewlabs-billu-backend/src/controllers/menuPermissionController.jsbrewlabs-billu-backend/src/services/databaseExecutor.jsbrewlabs-billu-backend/src/core/database/index.jsbrewlabs-billu-backend/src/modules/franchises/franchiseRoutes.jsbrewlabs-billu-backend/src/utils/standardResponse.jsbrewlabs-billu-backend/src/utils/constants.jsbrewlabs-billu-backend/src/utils/entityScopeRegistry.jsbrewlabs-billu-backend/src/utils/tenantScope.jsbrewlabs-billu-backend/src/config/orderStatuses.jsbrewlabs-billu-backend/src/workers/notificationWorker.jsbrewlabs-billu-backend/src/workers/fefoExpiryAlertCron.jsbrewlabs-billu-backend/src/tests/setup.jsbrewlabs-billu-backend/docs/LOCAL_SETUP.md