feat: stabilize API startup and lifecycle management

- Refactor server initialization to separate concerns and improve error handling.
- Implement centralized environment validation using Zod.
- Introduce database, Redis, and queue lifecycle management.
- Add health check endpoints for liveness and readiness.
- Enhance error handling middleware for better response structure.
- Implement rate limiting for API endpoints.
- Add request ID middleware for traceability.
- Create Sequelize CLI configuration and baseline migration for schema management.
- Establish CI workflow with Gitea for testing and syntax checks.
- Document foundational changes and migration strategy in PHASE_0_FOUNDATION_STABILIZATION.md.
- Add Docker Compose configuration for local development and testing.
- Implement unit and integration tests for critical functionality.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 13:33:26 +05:30
parent 624fce31c5
commit 267e80e2ec
40 changed files with 1682 additions and 261 deletions
+42 -35
View File
@@ -1,53 +1,60 @@
# App Details
APP_NAME=
# Required for API and worker startup
APP_NAME=ZUMRI
NODE_ENV=development
PORT=3070
FRONTEND_URL=http://localhost:3000
TRUST_PROXY=0
# Database configuration variables
DB_HOST =
DB_USER =
DB_PASSWORD =
DB_NAME =
DB_PORT =
DB_HOST=localhost
DB_PORT=3306
DB_NAME=zumri
DB_USER=zumri
DB_PASSWORD=replace_with_local_database_password
MYSQL_ROOT_PASSWORD=replace_with_local_root_password
# Redis configuration variables
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=replace_with_local_redis_password
# JWT secret key
JWT_SECRET = your_jwt_secret_key_here
JWT_EXPIRES_IN =1d
# Use separate randomly generated values of at least 32 characters.
JWT_SECRET=replace_with_a_random_value_at_least_32_chars
REFRESH_TOKEN_SECRET=replace_with_a_different_random_32_char_value
JWT_EXPIRES_IN=15m
REFRESH_TOKEN_DAYS=7d
# AWS S3 configuration
AWS_ACCESS_KEY_ID=your_aws_access_key_id_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here
AWS_REGION=your_aws_region_here
AWS_S3_BUCKET_NAME=your_aws_bucket_name_here
# Runtime controls
RUN_CRON=false
CACHE=true
JSON_BODY_LIMIT=1mb
API_RATE_LIMIT_WINDOW_MS=900000
API_RATE_LIMIT_MAX=300
SENSITIVE_RATE_LIMIT_WINDOW_MS=900000
SENSITIVE_RATE_LIMIT_MAX=20
SHUTDOWN_TIMEOUT_MS=10000
# Mail configuration
# Optional email feature
ENABLE_MAIL=false
MAIL_HOST=
MAIL_PORT=587
MAIL_USER=
MAIL_PASS=
MAIL_SECURE=false
MAIL_SECURE=false
MAIL_FROM=
# Documentation access credentials
# Optional S3 feature
ENABLE_S3=false
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_S3_BUCKET_NAME=
# Optional documentation login
DOCS_USER=
DOCS_PASS=
# Admin email for receiving notifications
# Optional application configuration
MANAGER_EMAIL=
# Caching configuration
CACHE=true
# Application environment
NODE_ENV=development
# User default password
DEFAULT_PASSWORD=
# Frontend URL for CORS
FRONTEND_URL=http://localhost:3000
# Puppeteer executable path (if needed, otherwise Puppeteer will use the bundled Chromium)
PUPPETEER_EXECUTABLE_PATH = C:\Users\User\.cache\puppeteer\chrome-headless-shell\win64-142.0.7444.162\chrome-headless-shell-win64\chrome-headless-shell.exe
PASSWORD_RESET_TTL_SECONDS=900
EMAIL_VERIFICATION_TTL_SECONDS=86400
PUPPETEER_EXECUTABLE_PATH=
+18
View File
@@ -0,0 +1,18 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check:syntax
- run: npm test -- --runInBand
+6
View File
@@ -0,0 +1,6 @@
const path = require("path");
module.exports = {
config: path.resolve("app/config/sequelize-cli.config.js"),
"migrations-path": path.resolve("migrations"),
};
+14 -36
View File
@@ -1,44 +1,22 @@
FROM node:20-slim
FROM node:22-slim
# Install Chromium + dependencies
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libxcomposite1 \
libxrandr2 \
libxdamage1 \
libgbm1 \
libasound2 \
libpangocairo-1.0-0 \
libpango-1.0-0 \
libnss3 \
libxss1 \
libgtk-3-0 \
libdrm2 \
libxshmfence1 \
ca-certificates \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Tell Puppeteer to use system Chromium
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Prevent Puppeteer from downloading its own Chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
chromium curl fonts-liberation libatk-bridge2.0-0 libatk1.0-0 libcups2 \
libxcomposite1 libxrandr2 libxdamage1 libgbm1 libasound2 libpangocairo-1.0-0 \
libpango-1.0-0 libnss3 libxss1 libgtk-3-0 libdrm2 libxshmfence1 ca-certificates \
--no-install-recommends && rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3070
CMD ["node", "server.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:3070/health/live > /dev/null || exit 1
CMD ["node", "server.js"]
+13
View File
@@ -360,3 +360,16 @@ Preserve and extend these concepts/files after adding tests: centralized Sequeli
- `npm audit --omit=dev` reported **28 production dependency vulnerabilities**: 19 high, 8 moderate, 1 low, 0 critical. Dependency upgrades were intentionally not performed.
- Broken local import scan found active-looking `documentJob.util.js -> ../queues/pdf.queue`; the commented future `email.worker` reference is not an active defect.
- Targeted dependency observations: Supertest, Swagger/OpenAPI tooling, Helmet, rate limiting, FCM, payment SDKs, and OpenAI SDK are missing. `nodemon` should be dev-only; `nodeman` and `pdfmake` appear unused. Confirm with runtime coverage before removal.
## Phase 0 Completion Update
**Date:** 2026-09-03
**Revised Day 1 completion:** approximately **92%**.
Phase 0 stabilized the existing foundation without adding commerce modules. Node/Docker now target Node 22; Zod validates required startup configuration and feature-gated mail/S3 configuration; unsafe JWT secret fallbacks are removed. Express construction is independent from listening, and `server.js` waits for successful MySQL authentication and Redis connectivity before accepting traffic. Runtime `sequelize.sync()` was removed and Sequelize CLI plus a non-destructive current-model baseline migration were added.
New `/health/live` and `/health/ready` routes provide real liveness/readiness behavior, while `/health` remains a liveness compatibility alias. Request IDs, Helmet, explicit body limits, general and sensitive rate limits, centralized 404/error handling, safer production request logging, configurable cron startup, and API/worker graceful shutdown are now present. Bull Board requires an authenticated `admin` or `superadmin` and displays all three existing queues. The existing router is available on both `/api` and `/api/v1`.
Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies.
Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`.
@@ -0,0 +1,140 @@
# ZUMRI Phase 0 Foundation Stabilization
## Objective
Stabilize the existing modular monolith so later identity and commerce work can build on deterministic startup, explicit schema management, observable health, baseline security, testability, and controlled shutdown. This phase does not add e-commerce domain behavior or intentionally redesign existing modules.
## Starting Problems
The API listener started before asynchronous database authentication, runtime `sequelize.sync()` was the schema strategy, `/health` returned before its database check and hardcoded other dependencies as healthy, Redis clients connected during imports, and there was no environment validation, global error/404 handling, request correlation, security headers, rate limiting, graceful shutdown, migration system, or tests. Bull Board was public. Docker targeted Node 20 while the architecture targets Node 22. See `Documentation/CURRENT_BACKEND_STATUS.md` for the full baseline audit.
## Changes Implemented
- Aligned package and container runtime to Node 22.
- Added centralized Zod environment validation with safe error messages.
- Separated Express construction (`app.js`) from dependency initialization and listening (`server.js`).
- Added database, Redis, queue, cron, API, and worker lifecycle handling.
- Removed runtime `sequelize.sync()` and introduced a Sequelize CLI baseline migration.
- Added liveness/readiness endpoints, request IDs, Helmet, body limits, general/sensitive rate limits, centralized errors, and centralized 404 behavior.
- Protected Bull Board with the existing JWT middleware and `admin`/`superadmin` account guard; registered activity, document, and log queues.
- Kept `/api` and added `/api/v1` as a backward-compatible alias.
- Added Jest/Supertest baseline tests and a portable JavaScript syntax-check command.
- Added Node 22 Docker hardening, development Compose, an Nginx example, and Gitea Actions CI.
- Restored the existing S3 utility interface through a feature-gated S3 client.
- Removed unsafe JWT/refresh-secret fallbacks and moved `nodemon` to development dependencies.
- Pinned `uuid` to the CommonJS-compatible v11 line after tests exposed that v13 could not be loaded by this CommonJS application.
## Application Startup Lifecycle
The API sequence is now:
1. Load `.env`.
2. Validate critical configuration without displaying values.
3. Authenticate Sequelize (no schema mutation).
4. connect to and ping Redis.
5. Load the Express app and queue resources.
6. Start cron only when `RUN_CRON=true`.
7. Start the HTTP listener.
8. On SIGTERM/SIGINT or fatal process error, stop accepting traffic, stop cron, close queues, Redis, and Sequelize, with a timeout guard.
Tests can import `app.js` without opening a TCP port. Startup failures prevent the listener from opening.
## Environment Variables
Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. JWT secrets must each be at least 32 characters. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication.
Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`.
Optional mail variables are required as a complete group only when `ENABLE_MAIL=true`: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`; `MAIL_SECURE` is optional. Optional S3 variables are required as a complete group only when `ENABLE_S3=true`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET_NAME`. Docs credentials remain optional. See `.env.sample`; never commit real values.
## Database Migration Strategy
Sequelize CLI uses `.sequelizerc`, `app/config/sequelize-cli.config.js`, and `migrations/20260903000000-current-schema-baseline.js`. Commands:
```text
npm run db:migrate:status
npm run db:migrate
npm run db:migrate:undo
```
The baseline represents all currently registered models and creates only tables whose names are absent. It does not drop, alter, or validate columns during `up`. For an existing deployment: take a backup, compare its schema to the migration/model definitions, test against a restored copy, resolve drift explicitly, then run the migration so Sequelize records it. Do not blindly run the baseline undo in an existing environment; its standard `down` removes baseline tables. No migration was executed during this phase.
Future schema changes require new forward migrations. Production no longer calls `sequelize.sync()`.
## Health Endpoints
- `GET /health/live`: process-only liveness; no dependencies queried.
- `GET /health/ready`: checks MySQL and Redis concurrently; returns 200/`ready` or 503/`not_ready`, exposing only `ok`/`error` states.
- `GET /health`: backward-compatible alias to liveness so existing monitors are not broken.
Container orchestration should normally use liveness for process restart and readiness for traffic admission.
## Security Middleware
Helmet is enabled; CSP is disabled globally for compatibility with the existing Bull Board/static documentation, while the board itself is authorization-protected. The API disables `X-Powered-By`, limits JSON and URL-encoded bodies to 1 MB by default, retains current credentialed CORS behavior, and accepts `X-Request-ID` only in a constrained safe format. Unknown routes and uncaught request errors use a standard response. Production 500 responses hide internal details.
JWT helpers have no public fallback secrets. Startup rejects missing/weak secrets. Morgan does not log Authorization, Cookie, or request bodies. Error logs contain request ID plus error name/message, not arbitrary error objects.
## Rate Limiting
Both `/api` and `/api/v1` use the configurable general limiter. The entire authentication router uses the stricter limiter, as do password reset requests/submissions and docs login. Health endpoints are outside limiters. `TRUST_PROXY` is an explicit numeric hop count rather than universal trust; set it to the exact Nginx hop count in deployment.
## Bull Board Security
`/admin/queues` now runs through the existing authentication middleware and accepts only actual User model administrator values: `admin` and `superadmin`. It has no hardcoded secondary credentials. Activity, document, and log queues are registered.
## Logging / Request IDs
Every request receives `req.id` and `X-Request-ID`; a safe incoming ID may be preserved. Development retains Morgan `dev`; production uses a concise method/path/status/timing line with request ID and no auth headers. The existing log queue remains in place. Phase 1 must remove remaining OTP/reset/session logging inside legacy authentication flows.
## Graceful Shutdown
The API handles SIGTERM, SIGINT, unhandled rejections, and uncaught exceptions. It closes the HTTP listener, cron tasks, API-owned queues, shared Redis, and Sequelize. Workers initialize required dependencies before accepting jobs and close worker instances, Redis, and Sequelize on the same signals/fatal conditions. `SHUTDOWN_TIMEOUT_MS` protects against indefinitely stuck shutdown.
## Cron Deployment Model
Cron is disabled unless `RUN_CRON=true`; tests do not start it. Until a distributed scheduler lock is added, enable it on exactly one API/scheduler instance. The cron launcher returns a stopper used during graceful shutdown.
## Testing
```text
npm run check:syntax
npm test -- --runInBand
npm run test:unit
npm run test:integration
```
Tests mock external infrastructure. Coverage includes liveness and readiness success/failure, `/health` compatibility, centralized 404/error responses, environment validation and optional features, authentication-required rejection, Bull Board rejection, and request IDs. No real MySQL, Redis, email, or S3 is required by the baseline suite.
## Docker
The existing image now uses `node:22-slim`, keeps system Chromium/Puppeteer support, installs production dependencies, copies files as the unprivileged `node` user, and includes a `/health/live` healthcheck. Build and configuration are still environment-driven.
## Local Development
Copy `.env.sample` to an ignored `.env`, replace all placeholder credentials/secrets, then run migrations explicitly before starting the API. `compose.yaml` provides API, worker, MySQL 8.4, and Redis 7.4 using the same application image and named data volumes. It does not auto-run migrations. Only the API port is published; MySQL/Redis remain internal.
## CI
`.gitea/workflows/ci.yml` uses checkout/setup-node actions, Node 22, `npm ci`, syntax checks, and Jest. It performs no deployment and requires no production credentials. Runner action mirroring/network policy remains an installation-specific Gitea concern.
## API Versioning Strategy
The existing router is mounted at both `/api` and `/api/v1`. Existing frontend calls remain valid, while new consumers should adopt `/api/v1`. A later compatibility window can deprecate `/api`; no route was mass-renamed in Phase 0.
## Known Remaining Issues
- Authentication contains in-memory OTP/refresh-session behavior and needs the dedicated Phase 1 security/session design; no Phase 1 feature was implemented here.
- Ownership/IDOR and role/account naming inconsistencies remain in legacy controllers/routes.
- Permission routes remain imported but unmounted and role linkage/cache behavior needs repair.
- Existing authentication utilities may still log OTP/reset/session material; remove and test during Phase 1.
- Docs authentication still uses a weak boolean cookie and should receive a server-authenticated session design.
- Activity/log queues need standardized retry, retention, idempotency, and sensitive-data sanitation.
- S3 is now correctly constructed only when enabled, but object authorization/content validation and lifecycle remain later work.
- The dependency audit still reports transitive vulnerabilities; forced/major upgrades were intentionally avoided.
- Migration baseline schema drift must be reviewed against any deployed database before first use.
- A distributed cron lock is not yet present.
## Phase 1 Prerequisites
The foundation is ready to begin Phase 1 once the baseline migration has been reviewed/tested against a copy of the deployment database and deployment secrets are configured. Phase 1 should focus on authentication/session durability, secure OTP/reset behavior, account status, role/permission integration, and ownership authorization without starting commerce modules.
+42 -72
View File
@@ -1,99 +1,69 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app.js
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const routes = require("./app/routes");
const cookieParser = require("cookie-parser");
const { bullBoardRouter } = require("./app/config/bullBoard.config");
const path = require("path");
const startAllCrons = require("./cron");
const db = require("./app/models");
// Test DB connection and sync models
(async () => {
try {
await db.sequelize.authenticate();
console.log("Database connected.");
await db.sequelize.sync();
console.log("Tables synced.");
// Start all cron jobs
startAllCrons();
} catch (error) {
console.error("DB error:", error);
}
})();
const routes = require("./app/routes");
const { healthRouter, live } = require("./app/routes/health.routes");
const { bullBoardRouter } = require("./app/config/bullBoard.config");
const { authenticate } = require("./app/middleware/auth.middleware");
const { authorizedAccountType } = require("./app/middleware/permission.middleware");
const requestId = require("./app/middleware/requestId.middleware");
const { generalApiLimiter } = require("./app/middleware/rateLimit.middleware");
const { notFound, errorHandler } = require("./app/middleware/error.middleware");
const app = express();
const trustProxy = Number(process.env.TRUST_PROXY || 0);
if (trustProxy > 0) app.set("trust proxy", trustProxy);
app.disable("x-powered-by");
app.use(requestId);
app.use(helmet({
contentSecurityPolicy: false,
hsts: process.env.NODE_ENV === "production" ? undefined : false,
}));
app.use(cookieParser());
// CORS configuration
const corsOptions = {
origin: process.env.FRONTEND_URL || "https://oceanic-demo.vercel.app",
app.use(cors({
origin: process.env.FRONTEND_URL,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Request-ID"],
credentials: true,
};
app.use(cors(corsOptions));
app.options(/.*/, cors(corsOptions));
}));
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(express.urlencoded({ extended: false, limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(express.json());
app.use(morgan("dev"));
morgan.token("request-id", (req) => req.id);
app.use(morgan(process.env.NODE_ENV === "production" ? ':remote-addr - :method :url :status :response-time ms req-id=:request-id' : "dev"));
app.get("/health", (req, res) => {
let dbStatus = "N/A";
let emailStatus = "N/A";
let redisStatus = "N/A";
app.get("/health", live);
app.use("/health", healthRouter);
db.sequelize
.authenticate()
.then(() => {
console.log("DB connection successful.");
dbStatus = "OK";
})
.catch((err) => {
console.error("DB connection error:", err);
res.status(500).send("Internal Server Error");
});
// Keep the legacy path while all new clients migrate to the versioned path.
app.use("/api", generalApiLimiter, routes);
app.use("/api/v1", generalApiLimiter, routes);
emailStatus = "OK";
redisStatus = "OK";
res.send({
status: "Online ✅",
database: dbStatus,
emailService: emailStatus,
redis: redisStatus,
});
});
app.use("/api", routes);
app.use(
"/Documentation",
(req, res, next) => {
if (req.path.endsWith(".md")) {
return res.status(403).send("Forbidden");
}
next();
},
(req, res, next) => req.path.endsWith(".md") ? res.status(403).send("Forbidden") : next(),
express.static(path.join(__dirname, "Documentation")),
);
app.use("/admin/queues", bullBoardRouter);
app.use(
"/admin/queues",
authenticate,
authorizedAccountType(["admin", "superadmin"]),
bullBoardRouter,
);
app.use(notFound);
app.use(errorHandler);
module.exports = app;
+4 -2
View File
@@ -14,16 +14,18 @@ const { ExpressAdapter } = require("@bull-board/express");
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
const activityQueue = require("../queues/activity.queue");
const documentQueue = require("../queues/document.queue");
const logQueue = require("../queues/log.queue");
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues");
const { addQueue, removeQueue, setQueues, replaceQueues } =
createBullBoard({
queues: [new BullMQAdapter(activityQueue)],
queues: [activityQueue, documentQueue, logQueue].map((queue) => new BullMQAdapter(queue)),
serverAdapter,
});
module.exports = {
bullBoardRouter: serverAdapter.getRouter(),
};
};
+9
View File
@@ -0,0 +1,9 @@
const db = require("../models");
const initializeDatabase = async () => db.sequelize.authenticate();
const checkDatabase = async () => {
try { await db.sequelize.authenticate(); return true; } catch (_error) { return false; }
};
const closeDatabase = async () => db.sequelize.close();
module.exports = { initializeDatabase, checkDatabase, closeDatabase };
+4 -4
View File
@@ -12,10 +12,10 @@
require("dotenv").config();
module.exports = {
HOST: process.env.DB_HOST || "localhost",
USER: process.env.DB_USER || "root",
PASSWORD: process.env.DB_PASSWORD || "",
DB: process.env.DB_NAME || "oceanic-db",
HOST: process.env.DB_HOST,
USER: process.env.DB_USER,
PASSWORD: process.env.DB_PASSWORD,
DB: process.env.DB_NAME,
PORT: process.env.DB_PORT || 3306,
DIALECT: "mysql",
+64
View File
@@ -0,0 +1,64 @@
const { z } = require("zod");
const booleanString = z.enum(["true", "false"]).default("false").transform((value) => value === "true");
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3070),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
DB_NAME: z.string().min(1),
DB_USER: z.string().min(1),
DB_PASSWORD: z.string(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
REFRESH_TOKEN_SECRET: z.string().min(32, "REFRESH_TOKEN_SECRET must contain at least 32 characters"),
REDIS_HOST: z.string().min(1),
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
REDIS_PASSWORD: z.string().optional(),
FRONTEND_URL: z.string().url(),
TRUST_PROXY: z.coerce.number().int().min(0).max(10).default(0),
JSON_BODY_LIMIT: z.string().default("1mb"),
API_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
API_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
SENSITIVE_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
SENSITIVE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20),
RUN_CRON: booleanString,
ENABLE_MAIL: booleanString,
ENABLE_S3: booleanString,
SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
CACHE: booleanString,
MAIL_HOST: z.string().min(1).optional(), MAIL_PORT: z.coerce.number().int().positive().optional(),
MAIL_SECURE: z.enum(["true", "false"]).optional(), MAIL_USER: z.string().optional(),
MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(),
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
}).superRefine((env, context) => {
const requireFeature = (enabled, names) => {
if (!enabled) return;
for (const name of names) {
if (!env[name]) context.addIssue({ code: "custom", path: [name], message: `${name} is required when enabled` });
}
};
requireFeature(env.ENABLE_MAIL, ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"]);
requireFeature(env.ENABLE_S3, ["AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_S3_BUCKET_NAME"]);
});
let validatedEnv;
const validateEnvironment = (source = process.env) => {
const result = envSchema.safeParse(source);
if (!result.success) {
const names = [...new Set(result.error.issues.map((issue) => issue.path.join(".") || "environment"))];
throw new Error(`Invalid environment configuration: ${names.join(", ")}`);
}
validatedEnv = result.data;
return validatedEnv;
};
const getEnvironment = () => validatedEnv || validateEnvironment();
const getOptionalFeatureStatus = (env = process.env) => ({
mail: Boolean(env.MAIL_HOST && env.MAIL_PORT && env.MAIL_USER && env.MAIL_PASS && env.MAIL_FROM),
s3: Boolean(env.AWS_REGION && env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_S3_BUCKET_NAME),
docs: Boolean(env.DOCS_USER && env.DOCS_PASS),
});
module.exports = { validateEnvironment, getEnvironment, getOptionalFeatureStatus };
+9
View File
@@ -0,0 +1,9 @@
const activityQueue = require("../queues/activity.queue");
const documentQueue = require("../queues/document.queue");
const logQueue = require("../queues/log.queue");
const closeQueues = async () => {
await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close()]);
};
module.exports = { closeQueues };
+2 -1
View File
@@ -11,13 +11,14 @@
const { Redis } = require("ioredis");
const createRedisConnection = () => {
const createRedisConnection = (options = {}) => {
const redis = new Redis({
host: process.env.REDIS_HOST || "redis",
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: options.lazyConnect ?? true,
});
// Connection events
+14
View File
@@ -0,0 +1,14 @@
const redis = require("./redisClient");
const initializeRedis = async () => {
if (redis.status === "wait") await redis.connect();
if (redis.status !== "ready") await redis.ping();
};
const checkRedis = async () => {
try { return (await redis.ping()) === "PONG"; } catch (_error) { return false; }
};
const closeRedis = async () => {
if (redis.status !== "end") await redis.quit();
};
module.exports = { initializeRedis, checkRedis, closeRedis };
+2 -2
View File
@@ -12,6 +12,6 @@
const createRedisConnection = require("./redis.config");
const redis = createRedisConnection();
const redis = createRedisConnection({ lazyConnect: true });
module.exports = redis;
module.exports = redis;
+10 -21
View File
@@ -1,22 +1,11 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const { S3Client } = require("@aws-sdk/client-s3");
// app/config/s3.config.js
// const { S3Client } = require("@aws-sdk/client-s3");
// const s3 = new S3Client({
// region: process.env.AWS_REGION,
// credentials: {
// accessKeyId: process.env.AWS_ACCESS_KEY_ID,
// secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
// },
// });
// module.exports = s3;
module.exports = process.env.ENABLE_S3 === "true"
? new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
})
: { send: async () => { throw new Error("S3 functionality is not enabled"); } };
+17
View File
@@ -0,0 +1,17 @@
require("dotenv").config();
const required = ["DB_HOST", "DB_NAME", "DB_USER"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) throw new Error(`Missing migration environment variables: ${missing.join(", ")}`);
const configuration = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD || "",
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
dialect: "mysql",
logging: false,
};
module.exports = { development: configuration, test: configuration, production: configuration };
+46
View File
@@ -0,0 +1,46 @@
const { ValidationError, UniqueConstraintError } = require("sequelize");
const { ZodError } = require("zod");
class AppError extends Error {
constructor(status, code, message, details) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
const notFound = (req, _res, next) => next(new AppError(404, "NOT_FOUND", "Route not found"));
const errorHandler = (error, req, res, _next) => {
let status = error.status || error.statusCode || 500;
let code = error.code || "INTERNAL_ERROR";
let message = error.message || "An unexpected error occurred";
let details = error.details;
if (error instanceof ZodError) {
status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data";
details = error.issues.map(({ path, message: detailMessage }) => ({ field: path.join("."), message: detailMessage }));
} else if (error instanceof UniqueConstraintError) {
status = 409; code = "CONFLICT"; message = "A record with these values already exists";
details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage }));
} else if (error instanceof ValidationError) {
status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data";
details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage }));
} else if (error.type === "entity.too.large") {
status = 413; code = "PAYLOAD_TOO_LARGE"; message = "Request body is too large";
} else if (error.name === "UnauthorizedError" || error.name === "JsonWebTokenError") {
status = 401; code = "UNAUTHORIZED"; message = "Authentication failed";
}
if (status >= 500) {
console.error(`[${req.id || "no-request-id"}] Request failed`, { name: error.name, message: error.message });
if (process.env.NODE_ENV === "production") message = "An unexpected error occurred";
}
const payload = { success: false, error: { code, message }, requestId: req.id };
if (details && status < 500) payload.error.details = details;
res.status(status).json(payload);
};
module.exports = { AppError, notFound, errorHandler };
+21
View File
@@ -0,0 +1,21 @@
const { rateLimit } = require("express-rate-limit");
const response = { success: false, error: { code: "RATE_LIMITED", message: "Too many requests" } };
const generalApiLimiter = rateLimit({
windowMs: Number(process.env.API_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
limit: Number(process.env.API_RATE_LIMIT_MAX) || 300,
standardHeaders: "draft-8",
legacyHeaders: false,
message: response,
});
const sensitiveLimiter = rateLimit({
windowMs: Number(process.env.SENSITIVE_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
limit: Number(process.env.SENSITIVE_RATE_LIMIT_MAX) || 20,
standardHeaders: "draft-8",
legacyHeaders: false,
message: response,
});
module.exports = { generalApiLimiter, sensitiveLimiter };
+10
View File
@@ -0,0 +1,10 @@
const { randomUUID } = require("crypto");
const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{8,128}$/;
module.exports = (req, res, next) => {
const supplied = req.get("x-request-id");
req.id = supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
res.setHeader("X-Request-ID", req.id);
next();
};
+2 -6
View File
@@ -13,11 +13,7 @@
const { Sequelize, DataTypes } = require("sequelize");
const dbConfig = require("../config/db.config");
let loggingOption = false;
if(process.env.NODE_ENV === 'production') {
loggingOption = true;
}
const loggingOption = process.env.NODE_ENV === "development" ? console.log : false;
const sequelize = new Sequelize(
dbConfig.DB,
@@ -76,4 +72,4 @@ Object.keys(db).forEach(model => {
module.exports = db;
module.exports = db;
+3
View File
@@ -13,6 +13,9 @@ const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.controller');
const {authenticate} = require('../middleware/auth.middleware');
const { sensitiveLimiter } = require('../middleware/rateLimit.middleware');
router.use(sensitiveLimiter);
// GET /api/auth/me
router.get("/me", authenticate, (req, res) => {
+2 -1
View File
@@ -6,6 +6,7 @@ const express = require("express");
const fs = require("fs");
const path = require("path");
const docsSession = require("../middleware/docsSession.middleware");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const router = express.Router();
@@ -47,7 +48,7 @@ router.get("/view/:file", docsSession, (req, res) => {
});
// Docs login (simple)
router.post("/login", (req, res) => {
router.post("/login", sensitiveLimiter, (req, res) => {
const { username, password } = req.body;
if (
+24
View File
@@ -0,0 +1,24 @@
const express = require("express");
const { checkDatabase } = require("../config/database.lifecycle");
const { checkRedis } = require("../config/redis.lifecycle");
const router = express.Router();
const live = (_req, res) => res.json({
status: "ok",
service: "zumri-api",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
router.get("/live", live);
router.get("/ready", async (_req, res) => {
const [database, redis] = await Promise.all([checkDatabase(), checkRedis()]);
const ready = database && redis;
res.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
checks: { database: database ? "ok" : "error", redis: redis ? "ok" : "error" },
});
});
module.exports = { healthRouter: router, live };
+3 -2
View File
@@ -19,10 +19,11 @@ const {
checkPermission,
} = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
router.post("/req-reset-password", profileController.requestPasswordReset);
router.post("/req-reset-password", sensitiveLimiter, profileController.requestPasswordReset);
router.post("/reset-password", profileController.resetPassword);
router.post("/reset-password", sensitiveLimiter, profileController.resetPassword);
router.post(
"/change-password",
+10 -6
View File
@@ -12,19 +12,23 @@
const jwt = require("jsonwebtoken");
require("dotenv").config();
const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret_key";
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || "your_refresh_token_secret_key";
const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity
const getSecret = (name) => {
const value = process.env[name];
if (!value || value.length < 32) throw new Error(`${name} is not configured securely`);
return value;
};
/**
* Generate JWT token
* @param {Object} payload - usually { id, email, role }
* @returns string
*/
const generateToken = (payload) => {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN });
};
/**
@@ -33,15 +37,15 @@ const generateToken = (payload) => {
* @returns payload or throws error
*/
const verifyToken = (token) => {
return jwt.verify(token, JWT_SECRET);
return jwt.verify(token, getSecret("JWT_SECRET"));
};
const generateRefreshToken = (payload) => {
return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_DAYS });
return jwt.sign(payload, getSecret("REFRESH_TOKEN_SECRET"), { expiresIn: REFRESH_TOKEN_DAYS });
}
const verifyRefreshToken = (token) => {
return jwt.verify(token, REFRESH_TOKEN_SECRET);
return jwt.verify(token, getSecret("REFRESH_TOKEN_SECRET"));
}
module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken };
+9 -4
View File
@@ -23,10 +23,12 @@ const transporter = nodemailer.createTransport({
auth: mailConfig.auth,
});
transporter.verify((err) => {
if (err) console.error("Mail server connection failed", err);
else console.log("Mail server ready");
});
if (process.env.NODE_ENV !== "test" && process.env.ENABLE_MAIL === "true") {
transporter.verify((err) => {
if (err) console.error("Mail server connection failed", { message: err.message });
else console.log("Mail server ready");
});
}
// Utility to load template and replace placeholders
const loadTemplate = (templateName, variables = {}) => {
@@ -42,6 +44,9 @@ const loadTemplate = (templateName, variables = {}) => {
};
const send = async ({ to, subject, templateName, templateVars = {}, text }) => {
if (process.env.ENABLE_MAIL !== "true") {
throw new Error("Email functionality is not enabled");
}
const html = templateName ? loadTemplate(templateName, templateVars) : undefined;
const mailOptions = {
+1 -2
View File
@@ -15,8 +15,7 @@ const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const s3 = require("../config/s3.config");
const { v4: uuidv4 } = require("uuid");
const path = require("path");
const createRedisConnection = require("../config/redis.config");
const redis = createRedisConnection();
const redis = require("../config/redisClient");
const { log } = require("./consoleLog.utill");
// Upload + return key (BEST PRACTICE)
+55 -26
View File
@@ -1,34 +1,63 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/workers/index.js
require("dotenv").config();
const { validateEnvironment } = require("../config/env.config");
require("dotenv").config()
const createActivityWorker = require("./activity.worker");
const createLogWorker = require("./log.worker");
const createDocumentWorker = require("./document.worker");
// Import future workers here
// const createEmailWorker = require("./email.worker");
console.log("🚀 Starting workers...");
// Initialize workers (async now)
(async () => {
const startWorkers = async () => {
const env = validateEnvironment();
const { initializeDatabase, closeDatabase } = require("../config/database.lifecycle");
const { initializeRedis, closeRedis } = require("../config/redis.lifecycle");
try {
createActivityWorker();
createLogWorker();
await createDocumentWorker();
console.log("✅ All workers started");
} catch (err) {
console.error("❌ Error starting workers:", err.message);
process.exit(1);
await initializeDatabase();
await initializeRedis();
} catch (error) {
await Promise.allSettled([closeRedis(), closeDatabase()]);
throw error;
}
})();
const { closeQueues } = require("../config/queue.lifecycle");
const createActivityWorker = require("./activity.worker");
const createLogWorker = require("./log.worker");
const createDocumentWorker = require("./document.worker");
const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker()];
console.log("All workers started.");
let shuttingDown = false;
const shutdown = async (reason, exitCode = 0) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Workers shutting down (${reason}).`);
const forceTimer = setTimeout(() => process.exit(1), env.SHUTDOWN_TIMEOUT_MS);
forceTimer.unref();
await Promise.allSettled(workers.map((worker) => worker.close()));
await closeQueues();
await closeRedis();
await closeDatabase();
clearTimeout(forceTimer);
process.exit(exitCode);
};
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error("Unhandled rejection");
console.error("Unhandled worker rejection", { name: error.name, message: error.message });
shutdown("unhandledRejection", 1);
});
process.once("uncaughtException", (error) => {
console.error("Uncaught worker exception", { name: error.name, message: error.message });
shutdown("uncaughtException", 1);
});
};
if (require.main === module) {
startWorkers().catch((error) => {
console.error("Worker startup failed", { name: error.name, message: error.message });
process.exitCode = 1;
});
}
module.exports = { startWorkers };
+59
View File
@@ -0,0 +1,59 @@
services:
api:
build: .
command: node server.js
env_file: .env
environment:
DB_HOST: mysql
REDIS_HOST: redis
ports:
- "${PORT:-3070}:${PORT:-3070}"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
worker:
build: .
command: node app/workers/index.js
env_file: .env
environment:
DB_HOST: mysql
REDIS_HOST: redis
RUN_CRON: "false"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7.4-alpine
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 10
volumes:
mysql-data:
redis-data:
+8 -2
View File
@@ -14,8 +14,14 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron
function startAllCrons() {
console.log("Starting Cron Jobs...");
startCleanInactiveNotificationsCron();
const tasks = [startCleanInactiveNotificationsCron()];
return async () => {
for (const task of tasks) {
task.stop();
if (typeof task.destroy === "function") task.destroy();
}
};
}
module.exports = startAllCrons;
module.exports = startAllCrons;
+22
View File
@@ -0,0 +1,22 @@
upstream zumri_api {
server 127.0.0.1:3070;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
client_max_body_size 6m;
location / {
proxy_pass http://zumri_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
@@ -0,0 +1,103 @@
"use strict";
const tableName = (value) => typeof value === "string" ? value : value.tableName;
module.exports = {
async up(queryInterface, Sequelize) {
const existing = new Set((await queryInterface.showAllTables()).map(tableName));
const create = async (name, columns, options = {}) => {
if (!existing.has(name)) await queryInterface.createTable(name, columns, options);
};
const timestamps = {
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
};
await create("users", {
id: { type: Sequelize.STRING, primaryKey: true }, firstName: { type: Sequelize.STRING, allowNull: false },
lastName: { type: Sequelize.STRING, allowNull: false }, email: { type: Sequelize.STRING, allowNull: false, unique: true },
password: { type: Sequelize.STRING, allowNull: false },
accountType: { type: Sequelize.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer", "support_agent"), defaultValue: "customer" },
accountStatus: { type: Sequelize.ENUM("PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"), allowNull: false, defaultValue: "PENDING_VERIFICATION" },
emailVerifiedAt: { type: Sequelize.DATE, allowNull: true, defaultValue: null }, ...timestamps,
});
await create("profiles", {
profile_id: { type: Sequelize.STRING, primaryKey: true },
user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } },
theme: { type: Sequelize.STRING, allowNull: false, defaultValue: "light" }, notificationsEnabled: { type: Sequelize.BOOLEAN, defaultValue: true },
profilePicture_id: { type: Sequelize.STRING, allowNull: true }, backgroundImage_id: { type: Sequelize.STRING, allowNull: true },
dob: { type: Sequelize.DATE, allowNull: true }, phone_number: { type: Sequelize.STRING, allowNull: true }, ...timestamps,
});
await create("customers", {
customer_id: { type: Sequelize.STRING, primaryKey: true },
user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } },
address: { type: Sequelize.STRING, allowNull: false }, phoneNumber: { type: Sequelize.STRING, allowNull: false }, ...timestamps,
});
await create("business_customers", {
business_customer_id: { type: Sequelize.STRING, primaryKey: true },
user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } },
businessName: { type: Sequelize.STRING, allowNull: false }, businessRegistrationNumber: { type: Sequelize.STRING, allowNull: false },
businessType: { type: Sequelize.STRING, allowNull: false }, contactName: { type: Sequelize.STRING, allowNull: false },
phoneNumber: { type: Sequelize.STRING, allowNull: false }, businessEmail: { type: Sequelize.STRING, allowNull: false },
expectedMonthlyVolume: { type: Sequelize.STRING, allowNull: false }, note: { type: Sequelize.STRING, allowNull: true }, ...timestamps,
});
await create("roles", {
role_id: { type: Sequelize.STRING, primaryKey: true }, roleName: { type: Sequelize.STRING, allowNull: false },
roleDescription: { type: Sequelize.TEXT, allowNull: true }, ...timestamps,
});
await create("permission", {
permission_id: { type: Sequelize.STRING, primaryKey: true }, permissionName: { type: Sequelize.STRING, allowNull: false },
permissionDescription: { type: Sequelize.TEXT, allowNull: true }, page: { type: Sequelize.STRING, allowNull: false },
module: { type: Sequelize.STRING, allowNull: false }, action: { type: Sequelize.STRING, allowNull: false }, ...timestamps,
});
await create("rolePermission", {
rp_id: { type: Sequelize.STRING, primaryKey: true }, role_id: { type: Sequelize.STRING, allowNull: false, references: { model: "roles", key: "role_id" } },
permission_id: { type: Sequelize.STRING, allowNull: false, references: { model: "permission", key: "permission_id" } }, ...timestamps,
});
await create("userPermission", {
up_id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" } },
permission_id: { type: Sequelize.STRING, allowNull: false, references: { model: "permission", key: "permission_id" } }, ...timestamps,
});
await create("uploads", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, file_path: { type: Sequelize.STRING, allowNull: false },
file_type: { type: Sequelize.STRING, allowNull: false }, file_size: { type: Sequelize.INTEGER, allowNull: false },
original_name: { type: Sequelize.STRING, allowNull: false }, use_for: { type: Sequelize.STRING, allowNull: false },
uploaded_by: { type: Sequelize.STRING, allowNull: false }, ...timestamps,
});
await create("UserActivity", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: Sequelize.STRING, allowNull: false },
username: { type: Sequelize.STRING, allowNull: false }, activity_description: { type: Sequelize.STRING, allowNull: false },
activity_type: { type: Sequelize.STRING, allowNull: true }, module: { type: Sequelize.STRING, allowNull: true },
activity_time: { type: Sequelize.DATE, allowNull: false }, activity_date: { type: Sequelize.DATEONLY, allowNull: false }, ...timestamps,
});
await create("Document", {
doc_id: { type: Sequelize.STRING, primaryKey: true }, reference_no: { type: Sequelize.STRING, allowNull: false },
doc_type: { type: Sequelize.STRING, allowNull: true, defaultValue: "N/A" }, data: { type: Sequelize.JSON, allowNull: false },
status: { type: Sequelize.STRING, allowNull: false, defaultValue: "DRAFT" }, ...timestamps,
});
await create("DocumentType", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
doc_type_name: { type: Sequelize.STRING, allowNull: true, defaultValue: "N/A" }, description: { type: Sequelize.JSON, allowNull: false }, ...timestamps,
});
await create("referenceNumbers", {
id: { type: Sequelize.STRING, primaryKey: true }, sequence_key: { type: Sequelize.STRING, allowNull: false, unique: true },
current_number: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, last_ref_number: { type: Sequelize.STRING }, ...timestamps,
});
await create("notification", {
notification_id: { type: Sequelize.STRING, primaryKey: true }, notificationHeadline: { type: Sequelize.STRING, allowNull: false },
notificationDescription: { type: Sequelize.TEXT, allowNull: true }, notificationType: { type: Sequelize.ENUM("USER", "ANNOUNCEMENT"), allowNull: false },
isActive: { type: Sequelize.BOOLEAN, defaultValue: true }, dateCreated: { type: Sequelize.DATE, defaultValue: Sequelize.fn("NOW") }, ...timestamps,
});
await create("user_notification", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: Sequelize.STRING(255), allowNull: false },
notification_id: { type: Sequelize.STRING, allowNull: false }, isRead: { type: Sequelize.BOOLEAN, defaultValue: false }, ...timestamps,
});
},
async down(queryInterface) {
for (const name of ["user_notification", "notification", "referenceNumbers", "DocumentType", "Document", "UserActivity", "uploads", "userPermission", "rolePermission", "permission", "roles", "business_customers", "customers", "profiles", "users"]) {
await queryInterface.dropTable(name);
}
},
};
+661 -19
View File
File diff suppressed because it is too large Load Diff
+28 -8
View File
@@ -1,5 +1,5 @@
{
"name": "backend",
"name": "zumri-backend",
"version": "1.0.0",
"main": "app.js",
"scripts": {
@@ -7,15 +7,30 @@
"start": "node server.js",
"worker": "node app/workers/index.js",
"seed": "node app/seeders/index.js",
"test": "jest"
"test": "jest",
"test:unit": "jest tests/unit --runInBand",
"test:integration": "jest tests/integration --runInBand",
"check:syntax": "node scripts/check-syntax.js",
"db:migrate": "sequelize-cli db:migrate",
"db:migrate:status": "sequelize-cli db:migrate:status",
"db:migrate:undo": "sequelize-cli db:migrate:undo"
},
"engines": {
"node": ">=22 <23"
},
"keywords": [
"Oceanic Titan",
"Oceanic Titan Backend"
"ZUMRI",
"ZUMRI Backend"
],
"author": "Niolla PVT (LTD)",
"license": "ISC",
"description": "Oceanic Titan Backend",
"description": "ZUMRI Backend",
"jest": {
"testEnvironment": "node",
"setupFiles": [
"<rootDir>/tests/setupEnv.js"
]
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1021.0",
"@aws-sdk/s3-request-presigner": "^3.1021.0",
@@ -30,6 +45,8 @@
"ejs": "^3.1.10",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
@@ -38,13 +55,16 @@
"node-cron": "^4.5.0",
"nodemailer": "^8.0.1",
"nodeman": "^1.1.2",
"nodemon": "^3.1.11",
"pdfmake": "^0.2.7",
"puppeteer": "^24.43.1",
"sequelize": "^6.37.7",
"uuid": "^13.0.0"
"uuid": "^11.1.1",
"zod": "^4.5.4"
},
"devDependencies": {
"jest": "^30.4.2"
"jest": "^30.4.2",
"nodemon": "^3.1.14",
"sequelize-cli": "^6.6.5",
"supertest": "^7.2.2"
}
}
+20
View File
@@ -0,0 +1,20 @@
const { readdirSync, statSync } = require("fs");
const { join } = require("path");
const { spawnSync } = require("child_process");
const ignored = new Set(["node_modules", ".git", "coverage"]);
const files = [];
const walk = (directory) => {
for (const name of readdirSync(directory)) {
if (ignored.has(name)) continue;
const target = join(directory, name);
if (statSync(target).isDirectory()) walk(target);
else if (name.endsWith(".js")) files.push(target);
}
};
walk(process.cwd());
for (const file of files) {
const result = spawnSync(process.execPath, ["--check", file], { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status || 1);
}
console.log(`Syntax check passed for ${files.length} JavaScript files.`);
+73 -12
View File
@@ -1,20 +1,81 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// server.js
require("dotenv").config();
const app = require("./app");
const { validateEnvironment } = require("./app/config/env.config");
const PORT = process.env.PORT || 3070;
const bootstrap = async () => {
const env = validateEnvironment();
const { initializeDatabase, closeDatabase } = require("./app/config/database.lifecycle");
const { initializeRedis, closeRedis } = require("./app/config/redis.lifecycle");
try {
await initializeDatabase();
console.log("Database connection ready.");
await initializeRedis();
console.log("Redis connection ready.");
} catch (error) {
await Promise.allSettled([closeRedis(), closeDatabase()]);
throw error;
}
// Start the server
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const { closeQueues } = require("./app/config/queue.lifecycle");
const startAllCrons = require("./cron");
const app = require("./app");
let stopCrons = async () => {};
if (env.RUN_CRON) stopCrons = startAllCrons();
const server = await new Promise((resolve, reject) => {
const listener = app.listen(env.PORT, "0.0.0.0", () => resolve(listener));
listener.once("error", reject);
});
console.log(`ZUMRI API listening on port ${env.PORT}.`);
let shuttingDown = false;
const shutdown = async (reason, exitCode = 0) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Shutting down (${reason}).`);
const forceTimer = setTimeout(() => {
console.error("Graceful shutdown timed out.");
process.exit(1);
}, env.SHUTDOWN_TIMEOUT_MS);
forceTimer.unref();
try {
await new Promise((resolve) => server.close(resolve));
await stopCrons();
await closeQueues();
await closeRedis();
await closeDatabase();
clearTimeout(forceTimer);
process.exit(exitCode);
} catch (error) {
console.error("Shutdown failed", { name: error.name, message: error.message });
process.exit(1);
}
};
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error("Unhandled rejection");
console.error("Unhandled rejection", { name: error.name, message: error.message });
shutdown("unhandledRejection", 1);
});
process.once("uncaughtException", (error) => {
console.error("Uncaught exception", { name: error.name, message: error.message });
shutdown("uncaughtException", 1);
});
return { server, shutdown };
};
if (require.main === module) {
bootstrap().catch((error) => {
console.error("API startup failed", { name: error.name, message: error.message });
process.exitCode = 1;
});
}
module.exports = { bootstrap };
+77
View File
@@ -0,0 +1,77 @@
const express = require("express");
const request = require("supertest");
const mockQueue = { add: jest.fn(), close: jest.fn(), on: jest.fn(), getJob: jest.fn() };
const mockCheckDatabase = jest.fn();
const mockCheckRedis = jest.fn();
jest.mock("../../app/queues/activity.queue", () => mockQueue);
jest.mock("../../app/queues/document.queue", () => mockQueue);
jest.mock("../../app/queues/log.queue", () => mockQueue);
jest.mock("../../app/config/redisClient", () => ({
status: "wait", connect: jest.fn(), ping: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn(), quit: jest.fn(),
}));
jest.mock("../../app/config/database.lifecycle", () => ({ checkDatabase: mockCheckDatabase }));
jest.mock("../../app/config/redis.lifecycle", () => ({ checkRedis: mockCheckRedis }));
jest.mock("../../app/config/bullBoard.config", () => {
const express = require("express");
return { bullBoardRouter: express.Router().get("/", (_req, res) => res.json({ ok: true })) };
});
const app = require("../../app");
const { AppError, errorHandler } = require("../../app/middleware/error.middleware");
describe("foundation HTTP behavior", () => {
beforeEach(() => {
mockCheckDatabase.mockResolvedValue(true);
mockCheckRedis.mockResolvedValue(true);
});
test("GET /health/live reports process liveness", async () => {
const response = await request(app).get("/health/live").expect(200);
expect(response.body).toMatchObject({ status: "ok", service: "zumri-api" });
expect(response.body.timestamp).toBeDefined();
expect(response.headers["x-request-id"]).toBeDefined();
});
test("legacy GET /health remains a liveness alias", async () => {
await request(app).get("/health").expect(200).expect(({ body }) => expect(body.status).toBe("ok"));
});
test("GET /health/ready checks database and Redis", async () => {
await request(app).get("/health/ready").expect(200).expect(({ body }) => {
expect(body).toEqual({ status: "ready", checks: { database: "ok", redis: "ok" } });
});
expect(mockCheckDatabase).toHaveBeenCalled();
expect(mockCheckRedis).toHaveBeenCalled();
});
test("GET /health/ready returns 503 when a dependency fails", async () => {
mockCheckDatabase.mockResolvedValueOnce(false);
await request(app).get("/health/ready").expect(503).expect(({ body }) => {
expect(body.status).toBe("not_ready");
expect(body.checks.database).toBe("error");
});
});
test("unknown routes use the centralized 404 response", async () => {
const response = await request(app).get("/does-not-exist").expect(404);
expect(response.body.error).toMatchObject({ code: "NOT_FOUND", message: "Route not found" });
});
test("global errors use the standard response and request ID", async () => {
const errorApp = express();
errorApp.use(require("../../app/middleware/requestId.middleware"));
errorApp.get("/error", (_req, _res, next) => next(new AppError(400, "TEST_ERROR", "Controlled failure")));
errorApp.use(errorHandler);
const response = await request(errorApp).get("/error").expect(400);
expect(response.body.error).toEqual({ code: "TEST_ERROR", message: "Controlled failure" });
expect(response.body.requestId).toBeDefined();
});
test("an authenticated route rejects missing credentials", async () => {
await request(app).get("/api/auth/me").expect(401).expect(({ body }) => expect(body.success).toBe(false));
});
test("Bull Board rejects unauthenticated requests", async () => {
await request(app).get("/admin/queues").expect(401);
});
});
+14
View File
@@ -0,0 +1,14 @@
process.env.NODE_ENV = "test";
process.env.PORT = "3070";
process.env.DB_HOST = "localhost";
process.env.DB_PORT = "3306";
process.env.DB_NAME = "zumri_test";
process.env.DB_USER = "zumri_test";
process.env.DB_PASSWORD = "test-only-password";
process.env.JWT_SECRET = "test-only-jwt-secret-value-32-characters";
process.env.REFRESH_TOKEN_SECRET = "test-only-refresh-secret-value-32-chars";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.FRONTEND_URL = "http://localhost:3000";
process.env.RUN_CRON = "false";
process.env.CACHE = "false";
+21
View File
@@ -0,0 +1,21 @@
const { validateEnvironment, getOptionalFeatureStatus } = require("../../app/config/env.config");
const valid = {
NODE_ENV: "test", PORT: "3070", DB_HOST: "localhost", DB_PORT: "3306", DB_NAME: "test",
DB_USER: "test", DB_PASSWORD: "", JWT_SECRET: "a".repeat(32), REFRESH_TOKEN_SECRET: "b".repeat(32),
REDIS_HOST: "localhost", REDIS_PORT: "6379", FRONTEND_URL: "http://localhost:3000",
};
describe("environment validation", () => {
test("accepts the critical API configuration", () => {
expect(validateEnvironment(valid)).toMatchObject({ PORT: 3070, DB_NAME: "test", RUN_CRON: false });
});
test("fails safely and names invalid variables without values", () => {
expect(() => validateEnvironment({ ...valid, JWT_SECRET: "short" })).toThrow("JWT_SECRET");
});
test("keeps mail and S3 optional", () => {
expect(getOptionalFeatureStatus(valid)).toMatchObject({ mail: false, s3: false });
});
});