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:
+42
-35
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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"); } };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
Generated
+661
-19
File diff suppressed because it is too large
Load Diff
+28
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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 };
|
||||
|
||||
Vendored
+77
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user