1519 lines
49 KiB
Markdown
1519 lines
49 KiB
Markdown
# 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 <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_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/<module>/<module>.test.js` mirrors `src/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.
|
||
|
||
```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.
|