erp_be/BACKEND_SETUP.md

1519 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.