49 KiB
ERP Backend Setup Specification (Phase 1)
Stack: Node.js (LTS 20+) · Express.js (JavaScript, CommonJS) · PostgreSQL 15+ · Prisma ORM Purpose: This document is the build spec for the backend scaffold — security, logging, error handling, RBAC, and the HMVC-style modular folder structure. Build it in the order given in Section 19.
1. Tech Stack Summary
| Concern | Choice |
|---|---|
| Runtime | Node.js 20 LTS |
| Framework | Express.js |
| Language | JavaScript (CommonJS — require/module.exports) |
| Database | PostgreSQL 15+ |
| ORM | Prisma |
| Auth | JWT (short-lived access + rotating refresh tokens) |
| Password hashing | bcrypt |
| Validation | Joi |
| Logging | Winston + winston-daily-rotate-file + Morgan |
| File uploads | Multer |
| API docs | swagger-jsdoc + swagger-ui-express |
| Testing | Jest + Supertest |
| Lint/format | ESLint + Prettier + Husky + lint-staged |
| Containerization | Docker + docker-compose |
2. Folder Structure (HMVC pattern)
Each business module is self-contained: routes → controller → service → (repository, for complex modules) → validation. Simple masters call Prisma directly from the service; complex transactional modules (Purchase Orders, GRN, Assets) split out a repository layer for multi-table writes/transactions.
erp-backend/
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seed.js
├── src/
│ ├── config/
│ │ ├── env.js
│ │ ├── logger.js
│ │ ├── morgan.js
│ │ ├── prisma.js
│ │ └── swagger.js
│ ├── middlewares/
│ │ ├── auth.middleware.js
│ │ ├── rbac.middleware.js
│ │ ├── validate.middleware.js
│ │ ├── rateLimiter.middleware.js
│ │ ├── upload.middleware.js
│ │ ├── requestId.middleware.js
│ │ └── error.middleware.js
│ ├── utils/
│ │ ├── ApiError.js
│ │ ├── ApiResponse.js
│ │ ├── asyncHandler.js
│ │ ├── encryption.js
│ │ ├── auditLog.js
│ │ ├── pagination.js
│ │ └── generateCode.js
│ ├── modules/
│ │ ├── auth/
│ │ │ ├── auth.routes.js
│ │ │ ├── auth.controller.js
│ │ │ ├── auth.service.js
│ │ │ └── auth.validation.js
│ │ ├── users/
│ │ ├── roles/
│ │ ├── masters/
│ │ │ ├── index.js
│ │ │ ├── uom/
│ │ │ ├── item-categories/
│ │ │ ├── item-subcategories/
│ │ │ ├── items/
│ │ │ ├── brands/
│ │ │ ├── gst-rates/
│ │ │ ├── warehouses/
│ │ │ ├── payment-terms/
│ │ │ ├── delivery-terms/
│ │ │ ├── asset-categories/
│ │ │ ├── departments/
│ │ │ ├── designations/
│ │ │ ├── plants/
│ │ │ └── document-series/
│ │ ├── vendors/
│ │ ├── purchase-orders/
│ │ ├── grn/
│ │ └── assets/
│ ├── routes/
│ │ └── v1/
│ │ └── index.js
│ ├── app.js
│ └── server.js
├── uploads/
├── logs/
├── tests/
│ └── modules/
├── .env.example
├── .eslintrc.json
├── .prettierrc
├── Dockerfile
├── docker-compose.yml
└── package.json
3. Environment Variables (.env.example)
NODE_ENV=development
PORT=3000
# Database
DATABASE_URL=postgresql://erp_user:erp_password@localhost:5432/erp_db?schema=public
# JWT
JWT_ACCESS_SECRET=replace_with_strong_random_value
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_SECRET=replace_with_another_strong_random_value
JWT_REFRESH_EXPIRY=7d
# Password hashing
BCRYPT_SALT_ROUNDS=12
# Field-level encryption (AES-256-GCM) - generate with: openssl rand -hex 32
ENCRYPTION_KEY=replace_with_64_char_hex_string
# HMAC key for blind-index (searchable encrypted fields)
ENCRYPTION_HMAC_KEY=replace_with_strong_random_value
# CORS - comma separated list of allowed origins
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100
AUTH_RATE_LIMIT_MAX=10
# Logging
LOG_LEVEL=info
# File uploads
UPLOAD_DIR=uploads
MAX_FILE_SIZE_MB=5
# Account lockout
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
All variables are validated on startup (Section 5.1) — the app must refuse to boot if a required variable is missing or malformed. Never commit .env; only .env.example.
4. Core Config Files
4.1 src/config/env.js — validated environment
const Joi = require('joi');
const envSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(3000),
DATABASE_URL: Joi.string().required(),
JWT_ACCESS_SECRET: Joi.string().min(32).required(),
JWT_ACCESS_EXPIRY: Joi.string().default('15m'),
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
JWT_REFRESH_EXPIRY: Joi.string().default('7d'),
BCRYPT_SALT_ROUNDS: Joi.number().default(12),
ENCRYPTION_KEY: Joi.string().length(64).required(),
ENCRYPTION_HMAC_KEY: Joi.string().min(32).required(),
CORS_ORIGINS: Joi.string().default('*'),
RATE_LIMIT_WINDOW_MS: Joi.number().default(900000),
RATE_LIMIT_MAX: Joi.number().default(100),
AUTH_RATE_LIMIT_MAX: Joi.number().default(10),
LOG_LEVEL: Joi.string().default('info'),
UPLOAD_DIR: Joi.string().default('uploads'),
MAX_FILE_SIZE_MB: Joi.number().default(5),
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
}).unknown();
const { error, value: env } = envSchema.validate(process.env);
if (error) {
throw new Error(`Environment validation error: ${error.message}`);
}
module.exports = env;
4.2 src/config/logger.js — Winston logger
const winston = require('winston');
require('winston-daily-rotate-file');
const path = require('path');
const env = require('./env');
const { combine, timestamp, printf, errors, json, colorize } = winston.format;
const SENSITIVE_KEYS = ['password', 'token', 'authorization', 'refresh_token', 'access_token'];
const redact = winston.format((info) => {
const scrub = (obj) => {
if (!obj || typeof obj !== 'object') return obj;
const out = Array.isArray(obj) ? [] : {};
for (const [k, v] of Object.entries(obj)) {
out[k] = SENSITIVE_KEYS.includes(k.toLowerCase()) ? '[REDACTED]' : scrub(v);
}
return out;
};
return scrub(info);
});
const devFormat = combine(
redact(),
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
printf(({ level, message, timestamp, stack, ...meta }) => {
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
return `${timestamp} [${level}]: ${stack || message} ${metaStr}`;
})
);
const prodFormat = combine(redact(), timestamp(), errors({ stack: true }), json());
const logger = winston.createLogger({
level: env.LOG_LEVEL,
format: env.NODE_ENV === 'production' ? prodFormat : devFormat,
transports: [
new winston.transports.Console(),
new winston.transports.DailyRotateFile({
filename: path.join('logs', 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxFiles: '30d',
}),
new winston.transports.DailyRotateFile({
filename: path.join('logs', 'combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxFiles: '30d',
}),
],
exitOnError: false,
});
module.exports = logger;
4.3 src/config/morgan.js — HTTP request logging into Winston
const morgan = require('morgan');
const logger = require('./logger');
morgan.token('id', (req) => req.id);
morgan.token('user', (req) => (req.user ? req.user.id : 'anonymous'));
const format =
':id :remote-addr :method :url :status :res[content-length]B - :response-time ms user=:user';
module.exports = morgan(format, {
stream: { write: (message) => logger.http(message.trim()) },
});
Winston's default
npmlog levels (error, warn, info, http, verbose, debug, silly) already includehttp, sologger.http(...)works out of the box.
4.4 src/config/prisma.js — Prisma client singleton with query/error logging
const { PrismaClient } = require('@prisma/client');
const logger = require('./logger');
const env = require('./env');
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'error' },
{ emit: 'event', level: 'warn' },
...(env.NODE_ENV === 'development' ? [{ emit: 'event', level: 'query' }] : []),
],
});
prisma.$on('error', (e) => logger.error('Prisma error', { error: e.message }));
prisma.$on('warn', (e) => logger.warn('Prisma warning', { warning: e.message }));
if (env.NODE_ENV === 'development') {
prisma.$on('query', (e) => logger.debug('Prisma query', { query: e.query, duration: e.duration }));
}
module.exports = prisma;
5. Security Implementation
5.1 Authentication — JWT access + rotating refresh tokens
- Access token: short-lived (default 15 min), sent in
Authorization: Bearer <token>header, carries{ sub: user.id, role_id }. - Refresh token: long-lived (default 7 days), random opaque string. Only its hash is stored in the
refresh_tokenstable (never the raw token). On refresh, the old token is revoked and a new one issued (rotation) — prevents replay if a token is leaked. - Password hashing: bcrypt, configurable cost factor (
BCRYPT_SALT_ROUNDS, default 12). - Account lockout:
users.failed_login_attemptsandusers.locked_untilcolumns. AfterMAX_LOGIN_ATTEMPTSconsecutive failures, lock forLOCKOUT_DURATION_MINUTES. Reset counter on successful login. - Password reset: one-time token (random, hashed, short expiry) stored in
password_reset_tokens, sent via email/SMS — never return the raw token in API responses outside the dedicated flow.
Prisma models to add (in addition to the Phase 1 business tables):
model RefreshToken {
id BigInt @id @default(autoincrement())
user_id BigInt
token_hash String @unique
expires_at DateTime
revoked_at DateTime?
created_at DateTime @default(now())
user User @relation(fields: [user_id], references: [id])
@@map("refresh_tokens")
}
model PasswordResetToken {
id BigInt @id @default(autoincrement())
user_id BigInt
token_hash String @unique
expires_at DateTime
used_at DateTime?
created_at DateTime @default(now())
@@map("password_reset_tokens")
}
5.2 src/middlewares/auth.middleware.js — verify JWT, load user + permissions
const jwt = require('jsonwebtoken');
const ApiError = require('../utils/ApiError');
const env = require('../config/env');
const prisma = require('../config/prisma');
module.exports = async (req, res, next) => {
try {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
throw new ApiError(401, 'Authentication token missing');
}
const token = header.split(' ')[1];
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET);
const user = await prisma.user.findFirst({
where: { id: BigInt(payload.sub), deleted_at: null },
include: {
role: {
include: { role_permissions: { include: { permission: { include: { module: true } } } } },
},
},
});
if (!user || !user.is_active || user.status !== 'active') {
throw new ApiError(401, 'Invalid or inactive user');
}
req.user = user;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') return next(new ApiError(401, 'Access token expired'));
if (err.name === 'JsonWebTokenError') return next(new ApiError(401, 'Invalid access token'));
next(err instanceof ApiError ? err : new ApiError(401, 'Unauthorized'));
}
};
5.3 src/middlewares/rbac.middleware.js — module/action permission check
const ApiError = require('../utils/ApiError');
const authorize = (moduleCode, action) => (req, res, next) => {
const permissions = req.user?.role?.role_permissions || [];
const allowed = permissions.some(
(rp) => rp.permission.module.code === moduleCode && rp.permission.action === action
);
if (!allowed) {
return next(new ApiError(403, `Forbidden: requires ${moduleCode}:${action}`));
}
next();
};
module.exports = authorize;
Usage in routes:
router.post('/', authenticate, authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create);
Permission actions per module: view, create, edit, delete, approve, export — matching the permissions table defined in the requirements document.
5.4 src/middlewares/validate.middleware.js — Joi request validation
const ApiError = require('../utils/ApiError');
const validate = (schema, source = 'body') => (req, res, next) => {
const { error, value } = schema.validate(req[source], { abortEarly: false, stripUnknown: true });
if (error) {
const errors = error.details.map((d) => ({ field: d.path.join('.'), message: d.message }));
return next(new ApiError(422, 'Validation failed', errors));
}
req[source] = value;
next();
};
module.exports = validate;
Usage: validate(createVendorSchema) for body (default), validate(listQuerySchema, 'query') for query params.
5.5 src/middlewares/rateLimiter.middleware.js
const rateLimit = require('express-rate-limit');
const env = require('../config/env');
const generalLimiter = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS,
max: env.RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, message: 'Too many requests, please try again later' },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: env.AUTH_RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
message: { success: false, message: 'Too many login attempts, please try again later' },
});
module.exports = { generalLimiter, authLimiter };
generalLimiter is applied to all /api routes; authLimiter additionally applied to /api/v1/auth/login and /api/v1/auth/forgot-password.
5.6 Security headers, CORS, HPP, compression
Configured centrally in app.js (Section 8):
- Helmet: sets
X-Content-Type-Options,X-Frame-Options,Strict-Transport-Security, removesX-Powered-By, and applies a Content-Security-Policy. CSP must be relaxed (or scoped) for the/api-docsroute since Swagger UI loads inline scripts/styles. - CORS: origins restricted to
CORS_ORIGINS(comma-separated env list) — never*in production.credentials: trueonly if refresh tokens are ever issued via httpOnly cookies (web admin). - HPP: strips duplicate query parameters (
hpppackage) to prevent HTTP parameter pollution. - Compression: gzip responses via
compressionmiddleware.
5.7 Field-level encryption — src/utils/encryption.js
For sensitive PII (vendor bank account numbers, user mobile numbers, etc.): encrypt with AES-256-GCM at rest, and maintain an HMAC-SHA256 blind index column (e.g. mobile_index) for equality search, exactly as done in the CI4 project.
const crypto = require('crypto');
const env = require('../config/env');
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(env.ENCRYPTION_KEY, 'hex'); // 32 bytes
const encrypt = (plainText) => {
if (plainText === null || plainText === undefined || plainText === '') return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
};
const decrypt = (payload) => {
if (!payload) return null;
const buffer = Buffer.from(payload, 'base64');
const iv = buffer.subarray(0, 12);
const authTag = buffer.subarray(12, 28);
const encrypted = buffer.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
};
const blindIndex = (value) => {
if (!value) return null;
return crypto
.createHmac('sha256', env.ENCRYPTION_HMAC_KEY)
.update(String(value).toLowerCase().trim())
.digest('hex');
};
module.exports = { encrypt, decrypt, blindIndex };
Apply this in the service layer when writing/reading users.mobile, vendor_bank_details.account_number, etc. — store both the encrypted value and its blind-index column, and query on the blind index.
5.8 File uploads — src/middlewares/upload.middleware.js
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const ApiError = require('../utils/ApiError');
const env = require('../config/env');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, env.UPLOAD_DIR),
filename: (req, file, cb) => {
const unique = crypto.randomBytes(16).toString('hex');
cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
},
});
const fileFilter = (req, file, cb) => {
if (!ALLOWED_MIME.includes(file.mimetype)) {
return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
}
cb(null, true);
};
module.exports = multer({
storage,
fileFilter,
limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
});
Notes: filenames are randomized (never trust the original name), the upload directory is not directly web-served — files are streamed through an authenticated, RBAC-checked download endpoint, and MIME type is validated against an allow-list (not the file extension alone).
5.9 Audit logging — src/utils/auditLog.js
const prisma = require('../config/prisma');
module.exports = async ({ tableName, recordId, action, oldValue, newValue, userId, requestId }) => {
await prisma.auditLog.create({
data: {
table_name: tableName,
record_id: BigInt(recordId),
action,
old_value: oldValue ?? undefined,
new_value: newValue ?? undefined,
performed_by: userId ? BigInt(userId) : null,
request_id: requestId,
},
});
};
Every create/update/status-change/approve/reject action in every service calls auditLog(...). Prisma model:
model AuditLog {
id BigInt @id @default(autoincrement())
table_name String
record_id BigInt
action String
old_value Json?
new_value Json?
performed_by BigInt?
request_id String?
performed_at DateTime @default(now())
@@map("audit_logs")
@@index([table_name, record_id])
}
6. Error Handling & Response Format
6.1 src/utils/ApiError.js
class ApiError extends Error {
constructor(statusCode, message, errors = [], isOperational = true) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = ApiError;
6.2 src/utils/ApiResponse.js
class ApiResponse {
constructor(statusCode, data = null, message = 'Success', meta = null) {
this.success = statusCode < 400;
this.message = message;
if (data !== null) this.data = data;
if (meta !== null) this.meta = meta;
}
}
module.exports = ApiResponse;
Standard success envelope:
{
"success": true,
"message": "Vendors fetched",
"data": [ ... ],
"meta": { "page": 1, "limit": 20, "total": 57 }
}
Standard error envelope:
{
"success": false,
"message": "Validation failed",
"errors": [{ "field": "gstin", "message": "\"gstin\" length must be 15 characters long" }]
}
6.3 src/utils/asyncHandler.js
module.exports = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
6.4 src/middlewares/error.middleware.js — global error handler (registered last)
const ApiError = require('../utils/ApiError');
const logger = require('../config/logger');
const env = require('../config/env');
module.exports = (err, req, res, next) => {
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal server error';
let errors = err.errors || [];
if (!(err instanceof ApiError)) {
statusCode = 500;
message = env.NODE_ENV === 'production' ? 'Internal server error' : err.message;
errors = [];
}
logger.error(message, {
requestId: req.id,
statusCode,
path: req.originalUrl,
method: req.method,
user: req.user ? req.user.id.toString() : undefined,
stack: err.stack,
});
res.status(statusCode).json({
success: false,
message,
errors,
...(env.NODE_ENV === 'development' && { stack: err.stack }),
});
};
6.5 src/middlewares/requestId.middleware.js
const { v4: uuidv4 } = require('uuid');
module.exports = (req, res, next) => {
req.id = req.headers['x-request-id'] || uuidv4();
res.setHeader('X-Request-Id', req.id);
next();
};
Every log line and audit log entry carries request_id, so a single request can be traced end-to-end across the access log, error log, and audit trail.
7. App & Server Bootstrap
7.1 src/app.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const hpp = require('hpp');
const compression = require('compression');
const swaggerUi = require('swagger-ui-express');
const env = require('./config/env');
const swaggerSpec = require('./config/swagger');
const morganMiddleware = require('./config/morgan');
const requestId = require('./middlewares/requestId.middleware');
const { generalLimiter } = require('./middlewares/rateLimiter.middleware');
const errorMiddleware = require('./middlewares/error.middleware');
const ApiError = require('./utils/ApiError');
const routesV1 = require('./routes/v1');
const app = express();
app.use(helmet());
app.use(cors({ origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()), credentials: true }));
app.use(hpp());
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(requestId);
app.use(morganMiddleware);
app.get('/health', (req, res) => res.json({ success: true, message: 'OK', uptime: process.uptime() }));
app.use('/api', generalLimiter);
app.use('/api/v1', routesV1);
app.use(
'/api-docs',
helmet({ contentSecurityPolicy: false }),
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
app.use((req, res, next) => next(new ApiError(404, `Route not found: ${req.originalUrl}`)));
app.use(errorMiddleware);
module.exports = app;
7.2 src/server.js
const app = require('./app');
const env = require('./config/env');
const logger = require('./config/logger');
const prisma = require('./config/prisma');
const server = app.listen(env.PORT, () => {
logger.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
const shutdown = async (signal) => {
logger.info(`${signal} received — shutting down gracefully`);
server.close(async () => {
await prisma.$disconnect();
logger.info('HTTP server closed, Prisma disconnected');
process.exit(0);
});
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection', { reason: reason?.message || reason });
});
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception — exiting', { error: err.message, stack: err.stack });
process.exit(1);
});
8. Prisma & Database Conventions
- Naming: Prisma models in
PascalCase(singular), mapped tosnake_caseplural tables via@@map. Fields staysnake_caseto match the schema in the requirements document — Prisma'scamelCaseis not required since we're using raw field names directly. - Common columns: every business table includes
is_active,created_by,updated_by,created_at,updated_at,deleted_at(nullable, soft delete). Example:
model Vendor {
id BigInt @id @default(autoincrement())
vendor_code String @unique
vendor_name String
vendor_type String
gstin String?
pan String?
status String @default("active") // active | inactive | blacklisted
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
deleted_at DateTime?
@@map("vendors")
}
- Soft delete everywhere on transactional tables: services filter
deleted_at: null; a "delete" endpoint setsdeleted_at = now(), neverDELETE FROM. - Migration workflow:
npx prisma migrate dev --name init
npx prisma generate
node prisma/seed.js
8.1 prisma/seed.js — seed modules, permissions, roles, default admin
The seed script must create, in order:
modulesrows for every Phase 1 module:USERS,ROLES,MASTERS,VENDOR,PURCHASE_ORDER,GRN,ASSET.permissionsrows: each module ×[view, create, edit, delete, approve, export](skipapprove/exportwhere not applicable).roles:Super Admin(all permissions),Admin,Purchase Manager,Store Manager,Accounts,Asset Manager.role_permissionsmapping per the matrix agreed with the client.- One bootstrap user with role
Super Admin, password hashed with bcrypt,status = active.
Run idempotently (upsert, not create) so re-running the seed doesn't duplicate data.
9. HMVC Module Pattern
9.1 Layer responsibilities
| Layer | File | Responsibility |
|---|---|---|
| Routes | *.routes.js |
Maps HTTP verb + path → middleware chain (authenticate, authorize, validate) → controller method |
| Controller | *.controller.js |
Parses request, calls service, shapes ApiResponse. No business logic, no Prisma calls. |
| Service | *.service.js |
Business logic, calls Prisma (directly for simple CRUD) or the repository (for multi-table transactions), calls auditLog. |
| Repository | *.repository.js |
(Complex modules only — PO, GRN, Assets) Encapsulates multi-table Prisma transactions (prisma.$transaction). |
| Validation | *.validation.js |
Joi schemas for create/update/query. |
9.2 Full example — Auth module
src/modules/auth/auth.validation.js
const Joi = require('joi');
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
});
const refreshSchema = Joi.object({
refresh_token: Joi.string().required(),
});
module.exports = { loginSchema, refreshSchema };
src/modules/auth/auth.service.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const env = require('../../config/env');
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
const issueTokens = async (user) => {
const accessToken = jwt.sign({ sub: user.id.toString(), role_id: user.role_id?.toString() }, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRY,
});
const refreshToken = crypto.randomBytes(40).toString('hex');
await prisma.refreshToken.create({
data: {
user_id: user.id,
token_hash: hashToken(refreshToken),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
};
const login = async (email, password) => {
const user = await prisma.user.findFirst({ where: { email, deleted_at: null } });
if (!user) throw new ApiError(401, 'Invalid email or password');
if (user.locked_until && user.locked_until > new Date()) {
throw new ApiError(423, 'Account locked due to too many failed attempts. Try again later.');
}
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
const attempts = user.failed_login_attempts + 1;
const lockout = attempts >= env.MAX_LOGIN_ATTEMPTS;
await prisma.user.update({
where: { id: user.id },
data: {
failed_login_attempts: lockout ? 0 : attempts,
locked_until: lockout ? new Date(Date.now() + env.LOCKOUT_DURATION_MINUTES * 60 * 1000) : null,
},
});
throw new ApiError(401, 'Invalid email or password');
}
if (user.status !== 'active' || !user.is_active) {
throw new ApiError(403, 'Account is inactive. Contact administrator.');
}
await prisma.user.update({
where: { id: user.id },
data: { failed_login_attempts: 0, locked_until: null, last_login_at: new Date() },
});
return issueTokens(user);
};
const refresh = async (refreshToken) => {
const tokenHash = hashToken(refreshToken);
const record = await prisma.refreshToken.findFirst({ where: { token_hash: tokenHash, revoked_at: null } });
if (!record || record.expires_at < new Date()) {
throw new ApiError(401, 'Invalid or expired refresh token');
}
await prisma.refreshToken.update({ where: { id: record.id }, data: { revoked_at: new Date() } });
const user = await prisma.user.findUnique({ where: { id: record.user_id } });
if (!user || !user.is_active) throw new ApiError(401, 'User not found or inactive');
return issueTokens(user);
};
const logout = async (refreshToken) => {
const tokenHash = hashToken(refreshToken);
await prisma.refreshToken.updateMany({
where: { token_hash: tokenHash, revoked_at: null },
data: { revoked_at: new Date() },
});
};
module.exports = { login, refresh, logout };
src/modules/auth/auth.controller.js
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const authService = require('./auth.service');
const login = asyncHandler(async (req, res) => {
const tokens = await authService.login(req.body.email, req.body.password);
res.json(new ApiResponse(200, tokens, 'Login successful'));
});
const refresh = asyncHandler(async (req, res) => {
const tokens = await authService.refresh(req.body.refresh_token);
res.json(new ApiResponse(200, tokens, 'Token refreshed'));
});
const logout = asyncHandler(async (req, res) => {
await authService.logout(req.body.refresh_token);
res.json(new ApiResponse(200, null, 'Logged out'));
});
module.exports = { login, refresh, logout };
src/modules/auth/auth.routes.js
const express = require('express');
const validate = require('../../middlewares/validate.middleware');
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
const { loginSchema, refreshSchema } = require('./auth.validation');
const controller = require('./auth.controller');
const router = express.Router();
router.post('/login', authLimiter, validate(loginSchema), controller.login);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(refreshSchema), controller.logout);
module.exports = router;
9.3 Full example — Vendors module (transactional CRUD with RBAC + audit)
src/modules/vendors/vendors.validation.js
const Joi = require('joi');
const createVendorSchema = Joi.object({
vendor_name: Joi.string().max(200).required(),
vendor_type: Joi.string().valid('RAW_MATERIAL', 'PACKING_MATERIAL', 'ASSET_CAPITAL', 'SERVICE', 'GENERAL').required(),
gstin: Joi.string().length(15).allow('').optional(),
pan: Joi.string().length(10).allow('').optional(),
payment_term_id: Joi.number().integer().optional(),
credit_period_days: Joi.number().integer().min(0).default(0),
remarks: Joi.string().allow('').optional(),
});
const updateVendorSchema = createVendorSchema.fork(
Object.keys(createVendorSchema.describe().keys),
(s) => s.optional()
);
const statusSchema = Joi.object({
status: Joi.string().valid('active', 'inactive', 'blacklisted').required(),
});
module.exports = { createVendorSchema, updateVendorSchema, statusSchema };
src/modules/vendors/vendors.service.js
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { nextDocumentNumber } = require('../../utils/generateCode');
const createVendor = async (data, userId, requestId) => {
const vendor_code = await nextDocumentNumber('VENDOR');
const vendor = await prisma.vendor.create({
data: { ...data, vendor_code, created_by: userId, updated_by: userId },
});
await auditLog({ tableName: 'vendors', recordId: vendor.id, action: 'CREATE', newValue: vendor, userId, requestId });
return vendor;
};
const getVendors = async ({ page, limit, search, status }) => {
const where = {
deleted_at: null,
...(status && { status }),
...(search && { vendor_name: { contains: search, mode: 'insensitive' } }),
};
const [data, total] = await Promise.all([
prisma.vendor.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { created_at: 'desc' } }),
prisma.vendor.count({ where }),
]);
return { data, total };
};
const getVendorById = async (id) => {
const vendor = await prisma.vendor.findFirst({ where: { id: BigInt(id), deleted_at: null } });
if (!vendor) throw new ApiError(404, 'Vendor not found');
return vendor;
};
const updateVendor = async (id, data, userId, requestId) => {
const existing = await getVendorById(id);
const vendor = await prisma.vendor.update({
where: { id: BigInt(id) },
data: { ...data, updated_by: userId },
});
await auditLog({
tableName: 'vendors',
recordId: id,
action: 'UPDATE',
oldValue: existing,
newValue: vendor,
userId,
requestId,
});
return vendor;
};
const setVendorStatus = async (id, status, userId, requestId) => {
const existing = await getVendorById(id);
if (existing.status === 'blacklisted' && status !== 'blacklisted') {
// Reactivating a blacklisted vendor — flagged for confirmation: should this
// require an additional approval permission (VENDOR:approve)?
}
const vendor = await prisma.vendor.update({ where: { id: BigInt(id) }, data: { status, updated_by: userId } });
await auditLog({
tableName: 'vendors',
recordId: id,
action: 'STATUS_CHANGE',
oldValue: { status: existing.status },
newValue: { status },
userId,
requestId,
});
return vendor;
};
module.exports = { createVendor, getVendors, getVendorById, updateVendor, setVendorStatus };
src/modules/vendors/vendors.controller.js
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./vendors.service');
const create = asyncHandler(async (req, res) => {
const vendor = await service.createVendor(req.body, req.user.id, req.id);
res.status(201).json(new ApiResponse(201, vendor, 'Vendor created successfully'));
});
const list = asyncHandler(async (req, res) => {
const page = Number(req.query.page) || 1;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const { data, total } = await service.getVendors({ page, limit, search: req.query.search, status: req.query.status });
res.json(new ApiResponse(200, data, 'Vendors fetched', { page, limit, total }));
});
const getOne = asyncHandler(async (req, res) => {
const vendor = await service.getVendorById(req.params.id);
res.json(new ApiResponse(200, vendor, 'Vendor fetched'));
});
const update = asyncHandler(async (req, res) => {
const vendor = await service.updateVendor(req.params.id, req.body, req.user.id, req.id);
res.json(new ApiResponse(200, vendor, 'Vendor updated successfully'));
});
const changeStatus = asyncHandler(async (req, res) => {
const vendor = await service.setVendorStatus(req.params.id, req.body.status, req.user.id, req.id);
res.json(new ApiResponse(200, vendor, 'Vendor status updated'));
});
module.exports = { create, list, getOne, update, changeStatus };
src/modules/vendors/vendors.routes.js
const express = require('express');
const authenticate = require('../../middlewares/auth.middleware');
const authorize = require('../../middlewares/rbac.middleware');
const validate = require('../../middlewares/validate.middleware');
const controller = require('./vendors.controller');
const { createVendorSchema, updateVendorSchema, statusSchema } = require('./vendors.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('VENDOR', 'view'), controller.list);
router.get('/:id', authorize('VENDOR', 'view'), controller.getOne);
router.post('/', authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create);
router.put('/:id', authorize('VENDOR', 'edit'), validate(updateVendorSchema), controller.update);
router.patch('/:id/status', authorize('VENDOR', 'edit'), validate(statusSchema), controller.changeStatus);
module.exports = router;
9.4 Masters module pattern
Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, Asset Categories, Departments, Designations, Plants, etc.) follows the same four-file pattern as Vendors, scaled down — simple name/code fields, no document-number generation, RBAC permission code MASTERS. Build one master fully (UOM is simplest) as the template, then replicate for the rest.
src/modules/masters/index.js aggregates all sub-routers:
const express = require('express');
const router = express.Router();
router.use('/uom', require('./uom/uom.routes'));
router.use('/item-categories', require('./item-categories/item-categories.routes'));
router.use('/item-subcategories', require('./item-subcategories/item-subcategories.routes'));
router.use('/items', require('./items/items.routes'));
router.use('/brands', require('./brands/brands.routes'));
router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
router.use('/warehouses', require('./warehouses/warehouses.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
router.use('/asset-categories', require('./asset-categories/asset-categories.routes'));
router.use('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes'));
router.use('/plants', require('./plants/plants.routes'));
router.use('/document-series', require('./document-series/document-series.routes'));
module.exports = router;
9.5 Document numbering — src/utils/generateCode.js
Backs the document_series master (PO numbers, GRN numbers, vendor codes, asset codes). Use a DB transaction with SELECT ... FOR UPDATE (or an atomic UPDATE ... RETURNING) on the series row to avoid duplicate numbers under concurrent requests:
const prisma = require('../config/prisma');
const nextDocumentNumber = async (seriesCode) =>
prisma.$transaction(async (tx) => {
const series = await tx.documentSeries.findUnique({ where: { code: seriesCode } });
if (!series) throw new Error(`Document series not configured: ${seriesCode}`);
const nextSeq = series.current_number + 1;
await tx.documentSeries.update({ where: { code: seriesCode }, data: { current_number: nextSeq } });
const padded = String(nextSeq).padStart(series.padding || 5, '0');
return `${series.prefix}${padded}`;
});
module.exports = { nextDocumentNumber };
9.6 Purchase Orders, GRN, Assets
These follow the Vendors pattern but with a *.repository.js for multi-table writes:
- Purchase Orders:
purchase-orders.repository.jswraps creating the PO header + line items in oneprisma.$transaction. Status-change endpoints (submit,approve,reject,amend,cancel) each callauditLogand checkauthorize('PURCHASE_ORDER', 'approve')for the approval endpoints specifically (separate fromedit). - GRN:
grn.repository.jswraps creating the GRN header + line items + updatingpurchase_order_items.received_qty+ recalculating PO status, all inside one transaction. If a received item is flaggedis_asset_item, the same transaction creates the correspondingassetsrows. - Assets: standard CRUD plus
assets.transferendpoint which writes anasset_transfersrow and updates the asset's current location/department/assignee in one transaction.
10. API Conventions
- Versioning: all routes under
/api/v1/.... Breaking changes go to/api/v2. - Pagination:
?page=1&limit=20(limit capped at 100 server-side). Responsemeta: { page, limit, total }. - Filtering:
?status=approved&search=keyword— each module documents its filterable fields. - Sorting:
?sort=-created_at(-prefix = descending). - IDs: all primary keys are
BigInt— serialized as strings in JSON (configure a global JSON serializer forBigInt, sinceJSON.stringifycannot handle it natively):
// at the top of src/server.js or app.js
BigInt.prototype.toJSON = function () {
return this.toString();
};
- Dates: ISO 8601 (
YYYY-MM-DDTHH:mm:ss.sssZ), UTC. Convert to IST only in the Flutter client.
11. Phase 1 Route Map
/api/v1/auth/login
/api/v1/auth/refresh
/api/v1/auth/logout
/api/v1/users (CRUD, RBAC: USERS)
/api/v1/roles (CRUD + permission assignment, RBAC: ROLES)
/api/v1/masters/uom
/api/v1/masters/item-categories
/api/v1/masters/item-subcategories
/api/v1/masters/items
/api/v1/masters/brands
/api/v1/masters/gst-rates
/api/v1/masters/warehouses
/api/v1/masters/payment-terms
/api/v1/masters/delivery-terms
/api/v1/masters/asset-categories
/api/v1/masters/departments
/api/v1/masters/designations
/api/v1/masters/plants
/api/v1/masters/document-series
/api/v1/vendors (+ /addresses, /contacts, /bank-details sub-routes)
/api/v1/purchase-orders (+ /submit, /approve, /reject, /amend, /cancel, /pdf)
/api/v1/grn (+ /cancel, /pdf)
/api/v1/assets (+ /transfer, /transfer-history)
12. Testing Setup
- Jest as the runner, Supertest for HTTP-level integration tests against
app.js(no live server needed). - Separate test database via
DATABASE_URLoverride in.env.test; runprisma migrate deployagainst it before the suite. tests/modules/<module>/<module>.test.jsmirrorssrc/modules/<module>/.- Minimum coverage for Phase 1: auth (login/refresh/lockout), RBAC middleware (allowed vs forbidden), vendor CRUD + status transitions, PO status lifecycle, GRN partial-receipt → PO status recalculation.
// jest.config.js
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
setupFiles: ['dotenv/config'],
};
13. Code Quality
.eslintrc.json:
{
"env": { "node": true, "es2022": true, "jest": true },
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"parserOptions": { "ecmaVersion": 2022, "sourceType": "script" },
"rules": {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-console": "warn"
}
}
.prettierrc:
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100
}
Husky + lint-staged (package.json):
{
"lint-staged": {
"src/**/*.js": ["eslint --fix", "prettier --write"]
}
}
14. API Documentation — Swagger
src/config/swagger.js:
const swaggerJsdoc = require('swagger-jsdoc');
module.exports = swaggerJsdoc({
definition: {
openapi: '3.0.0',
info: { title: 'ERP API', version: '1.0.0', description: 'Phase 1 — PO, GRN, Vendor, Assets, Masters, Users & RBAC' },
servers: [{ url: '/api/v1' }],
components: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
},
security: [{ bearerAuth: [] }],
},
apis: ['./src/modules/**/*.routes.js'],
});
Each route file carries @swagger JSDoc blocks above each route definition describing path, params, request body schema, and responses.
15. Docker
Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npx prisma generate
EXPOSE 3000
CMD ["node", "src/server.js"]
docker-compose.yml:
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
env_file: .env
depends_on:
- postgres
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: erp_user
POSTGRES_PASSWORD: erp_password
POSTGRES_DB: erp_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
16. package.json — dependencies & scripts
{
"name": "erp-backend",
"version": "1.0.0",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"lint": "eslint src --ext .js",
"lint:fix": "eslint src --ext .js --fix",
"format": "prettier --write \"src/**/*.js\"",
"test": "jest --runInBand",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "node prisma/seed.js",
"prepare": "husky install"
},
"dependencies": {
"@prisma/client": "^5.x",
"express": "^4.x",
"joi": "^17.x",
"jsonwebtoken": "^9.x",
"bcrypt": "^5.x",
"helmet": "^7.x",
"cors": "^2.x",
"hpp": "^0.2.x",
"compression": "^1.x",
"express-rate-limit": "^7.x",
"winston": "^3.x",
"winston-daily-rotate-file": "^5.x",
"morgan": "^1.x",
"multer": "^1.x",
"uuid": "^9.x",
"dotenv": "^16.x",
"swagger-jsdoc": "^6.x",
"swagger-ui-express": "^5.x"
},
"devDependencies": {
"prisma": "^5.x",
"nodemon": "^3.x",
"eslint": "^8.x",
"eslint-config-prettier": "^9.x",
"eslint-plugin-prettier": "^5.x",
"prettier": "^3.x",
"jest": "^29.x",
"supertest": "^6.x",
"husky": "^9.x",
"lint-staged": "^15.x"
}
}
17. Security Checklist (OWASP-mapped)
| Concern | Mitigation in this setup |
|---|---|
| SQL injection | Prisma parameterized queries everywhere; never raw string-concatenated SQL |
| Broken authentication | bcrypt password hashing, short-lived JWT access tokens, rotating hashed refresh tokens, account lockout after repeated failures |
| Broken access control | authenticate + authorize(module, action) on every protected route; soft-deleted/inactive users rejected at auth |
| Sensitive data exposure | AES-256-GCM field encryption + HMAC blind index for PII; secrets only in .env (never committed); Winston redacts password/token fields |
| Security misconfiguration | Helmet security headers, env validated on boot (fails fast if misconfigured), no default credentials in seed beyond a documented bootstrap admin |
| Brute force / DoS | express-rate-limit global + stricter limiter on /auth/login |
| Cross-site scripting (XSS) | Joi input validation/sanitization on every endpoint; JSON-only API (no server-rendered HTML); Flutter client responsible for output encoding |
| CSRF | Not applicable for header-based Bearer JWT (no cookies); if a web admin later uses httpOnly cookies for refresh tokens, add csurf / double-submit cookie pattern |
| Insecure file upload | Multer MIME allow-list, random filenames, size limit, files served only via authenticated/RBAC-checked endpoints |
| Insufficient logging & monitoring | Winston (console + daily-rotated error/combined logs), Morgan HTTP access logs, audit_logs table for all data-changing actions, X-Request-Id correlation across logs |
| Dependency vulnerabilities | Run npm audit in CI; keep package-lock.json committed |
18. Build Order (for Cursor)
package.json, install dependencies,.env.example,.eslintrc.json,.prettierrc.prisma/schema.prisma— start withUser,Role,Module,Permission,RolePermission,RefreshToken,PasswordResetToken,AuditLog,DocumentSeries, then add the Phase 1 business tables (Vendor + sub-tables, PurchaseOrder + items, Grn + items, Asset + transfers, all masters) from the requirements document.src/config/*(env, logger, morgan, prisma, swagger).src/utils/*(ApiError, ApiResponse, asyncHandler, encryption, auditLog, generateCode, pagination).src/middlewares/*(requestId, error, validate, rateLimiter, auth, rbac, upload).src/app.js,src/server.js, confirm/healthresponds.prisma/seed.js— modules, permissions, roles, bootstrap admin.authmodule end-to-end (login/refresh/logout) — confirm JWT + RBAC works against a protected test route.mastersmodules (UOM first as the template, then replicate for the rest).vendorsmodule (full pattern with addresses/contacts/bank-details sub-resources).purchase-ordersmodule (header + items + approval workflow + repository transaction).grnmodule (receipt + PO status recalculation + asset auto-creation transaction).assetsmodule (CRUD + transfer history).- Swagger annotations across all route files.
- Jest test suites per module.
- Dockerfile + docker-compose, verify
docker-compose upboots API + Postgres.