# 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/ │ │ │ ├── hsn-codes/ │ │ │ ├── warehouses/ │ │ │ ├── payment-terms/ │ │ │ ├── delivery-terms/ │ │ │ ├── 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`) ```env 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 ```js 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 ```js 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 ```js 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 `npm` log levels (`error, warn, info, http, verbose, debug, silly`) already include `http`, so `logger.http(...)` works out of the box. ### 4.4 `src/config/prisma.js` — Prisma client singleton with query/error logging ```js 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 ` 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_tokens` table (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_attempts` and `users.locked_until` columns. After `MAX_LOGIN_ATTEMPTS` consecutive failures, lock for `LOCKOUT_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): ```prisma 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 ```js 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 ```js 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: ```js 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 ```js 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` ```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`, removes `X-Powered-By`, and applies a Content-Security-Policy. CSP must be relaxed (or scoped) for the `/api-docs` route since Swagger UI loads inline scripts/styles. - **CORS**: origins restricted to `CORS_ORIGINS` (comma-separated env list) — never `*` in production. `credentials: true` only if refresh tokens are ever issued via httpOnly cookies (web admin). - **HPP**: strips duplicate query parameters (`hpp` package) to prevent HTTP parameter pollution. - **Compression**: gzip responses via `compression` middleware. ### 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. ```js 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` ```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` ```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: ```prisma 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` ```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` ```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: ```json { "success": true, "message": "Vendors fetched", "data": [ ... ], "meta": { "page": 1, "limit": 20, "total": 57 } } ``` Standard error envelope: ```json { "success": false, "message": "Validation failed", "errors": [{ "field": "gstin", "message": "\"gstin\" length must be 15 characters long" }] } ``` ### 6.3 `src/utils/asyncHandler.js` ```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) ```js 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` ```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` ```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` ```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 to `snake_case` plural tables via `@@map`. Fields stay `snake_case` to match the schema in the requirements document — Prisma's `camelCase` is 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: ```prisma 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 sets `deleted_at = now()`, never `DELETE FROM`. - **Migration workflow**: ```bash 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: 1. `modules` rows for every Phase 1 module: `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET`. 2. `permissions` rows: each module × `[view, create, edit, delete, approve, export]` (skip `approve`/`export` where not applicable). 3. `roles`: `Super Admin` (all permissions), `Admin`, `Purchase Manager`, `Store Manager`, `Accounts`, `Asset Manager`. 4. `role_permissions` mapping per the matrix agreed with the client. 5. 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`** ```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`** ```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`** ```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`** ```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`** ```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`** ```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`** ```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`** ```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, 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. Assets use the same item category / subcategory masters (no separate asset category APIs). `src/modules/masters/index.js` aggregates all sub-routers: ```js 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('/hsn-codes', require('./hsn-codes/hsn-codes.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('/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: ```js 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.js` wraps creating the PO header + line items in one `prisma.$transaction`. Status-change endpoints (`submit`, `approve`, `reject`, `amend`, `cancel`) each call `auditLog` and check `authorize('PURCHASE_ORDER', 'approve')` for the approval endpoints specifically (separate from `edit`). - **GRN**: `grn.repository.js` wraps creating the GRN header + line items + updating `purchase_order_items.received_qty` + recalculating PO status, all inside one transaction. If a received item is flagged `is_asset_item`, the same transaction creates the corresponding `assets` rows. - **Assets**: standard CRUD plus `assets.transfer` endpoint which writes an `asset_transfers` row 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). Response `meta: { 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 for `BigInt`, since `JSON.stringify` cannot handle it natively): ```js // 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/hsn-codes /api/v1/masters/warehouses /api/v1/masters/payment-terms /api/v1/masters/delivery-terms /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_URL` override in `.env.test`; run `prisma migrate deploy` against it before the suite. - `tests/modules//.test.js` mirrors `src/modules//`. - 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. ```json // jest.config.js module.exports = { testEnvironment: 'node', testMatch: ['**/tests/**/*.test.js'], setupFiles: ['dotenv/config'], }; ``` --- ## 13. Code Quality `.eslintrc.json`: ```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`: ```json { "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100 } ``` Husky + lint-staged (`package.json`): ```json { "lint-staged": { "src/**/*.js": ["eslint --fix", "prettier --write"] } } ``` --- ## 14. API Documentation — Swagger `src/config/swagger.js`: ```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`: ```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`: ```yaml 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 ```json { "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) 1. `package.json`, install dependencies, `.env.example`, `.eslintrc.json`, `.prettierrc`. 2. `prisma/schema.prisma` — start with `User`, `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. 3. `src/config/*` (env, logger, morgan, prisma, swagger). 4. `src/utils/*` (ApiError, ApiResponse, asyncHandler, encryption, auditLog, generateCode, pagination). 5. `src/middlewares/*` (requestId, error, validate, rateLimiter, auth, rbac, upload). 6. `src/app.js`, `src/server.js`, confirm `/health` responds. 7. `prisma/seed.js` — modules, permissions, roles, bootstrap admin. 8. `auth` module end-to-end (login/refresh/logout) — confirm JWT + RBAC works against a protected test route. 9. `masters` modules (UOM first as the template, then replicate for the rest). 10. `vendors` module (full pattern with addresses/contacts/bank-details sub-resources). 11. `purchase-orders` module (header + items + approval workflow + repository transaction). 12. `grn` module (receipt + PO status recalculation + asset auto-creation transaction). 13. `assets` module (CRUD + transfer history). 14. Swagger annotations across all route files. 15. Jest test suites per module. 16. Dockerfile + docker-compose, verify `docker-compose up` boots API + Postgres.