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
+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 };