erp_be/BACKEND_SETUP.md

49 KiB
Raw Permalink Blame History

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)

NODE_ENV=development
PORT=3000

# Database
DATABASE_URL=postgresql://erp_user:erp_password@localhost:5432/erp_db?schema=public

# JWT
JWT_ACCESS_SECRET=replace_with_strong_random_value
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_SECRET=replace_with_another_strong_random_value
JWT_REFRESH_EXPIRY=7d

# Password hashing
BCRYPT_SALT_ROUNDS=12

# Field-level encryption (AES-256-GCM) - generate with: openssl rand -hex 32
ENCRYPTION_KEY=replace_with_64_char_hex_string
# HMAC key for blind-index (searchable encrypted fields)
ENCRYPTION_HMAC_KEY=replace_with_strong_random_value

# CORS - comma separated list of allowed origins
CORS_ORIGINS=http://localhost:3000,http://localhost:8080

# Rate limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100
AUTH_RATE_LIMIT_MAX=10

# Logging
LOG_LEVEL=info

# File uploads
UPLOAD_DIR=uploads
MAX_FILE_SIZE_MB=5

# Account lockout
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30

All variables are validated on startup (Section 5.1) — the app must refuse to boot if a required variable is missing or malformed. Never commit .env; only .env.example.


4. Core Config Files

4.1 src/config/env.js — validated environment

const Joi = require('joi');

const envSchema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
  PORT: Joi.number().default(3000),

  DATABASE_URL: Joi.string().required(),

  JWT_ACCESS_SECRET: Joi.string().min(32).required(),
  JWT_ACCESS_EXPIRY: Joi.string().default('15m'),
  JWT_REFRESH_SECRET: Joi.string().min(32).required(),
  JWT_REFRESH_EXPIRY: Joi.string().default('7d'),

  BCRYPT_SALT_ROUNDS: Joi.number().default(12),

  ENCRYPTION_KEY: Joi.string().length(64).required(),
  ENCRYPTION_HMAC_KEY: Joi.string().min(32).required(),

  CORS_ORIGINS: Joi.string().default('*'),

  RATE_LIMIT_WINDOW_MS: Joi.number().default(900000),
  RATE_LIMIT_MAX: Joi.number().default(100),
  AUTH_RATE_LIMIT_MAX: Joi.number().default(10),

  LOG_LEVEL: Joi.string().default('info'),

  UPLOAD_DIR: Joi.string().default('uploads'),
  MAX_FILE_SIZE_MB: Joi.number().default(5),

  MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
  LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
}).unknown();

const { error, value: env } = envSchema.validate(process.env);
if (error) {
  throw new Error(`Environment validation error: ${error.message}`);
}

module.exports = env;

4.2 src/config/logger.js — Winston logger

const winston = require('winston');
require('winston-daily-rotate-file');
const path = require('path');
const env = require('./env');

const { combine, timestamp, printf, errors, json, colorize } = winston.format;

const SENSITIVE_KEYS = ['password', 'token', 'authorization', 'refresh_token', 'access_token'];

const redact = winston.format((info) => {
  const scrub = (obj) => {
    if (!obj || typeof obj !== 'object') return obj;
    const out = Array.isArray(obj) ? [] : {};
    for (const [k, v] of Object.entries(obj)) {
      out[k] = SENSITIVE_KEYS.includes(k.toLowerCase()) ? '[REDACTED]' : scrub(v);
    }
    return out;
  };
  return scrub(info);
});

const devFormat = combine(
  redact(),
  colorize(),
  timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  errors({ stack: true }),
  printf(({ level, message, timestamp, stack, ...meta }) => {
    const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
    return `${timestamp} [${level}]: ${stack || message} ${metaStr}`;
  })
);

const prodFormat = combine(redact(), timestamp(), errors({ stack: true }), json());

const logger = winston.createLogger({
  level: env.LOG_LEVEL,
  format: env.NODE_ENV === 'production' ? prodFormat : devFormat,
  transports: [
    new winston.transports.Console(),
    new winston.transports.DailyRotateFile({
      filename: path.join('logs', 'error-%DATE%.log'),
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      maxFiles: '30d',
    }),
    new winston.transports.DailyRotateFile({
      filename: path.join('logs', 'combined-%DATE%.log'),
      datePattern: 'YYYY-MM-DD',
      maxFiles: '30d',
    }),
  ],
  exitOnError: false,
});

module.exports = logger;

4.3 src/config/morgan.js — HTTP request logging into Winston

const morgan = require('morgan');
const logger = require('./logger');

morgan.token('id', (req) => req.id);
morgan.token('user', (req) => (req.user ? req.user.id : 'anonymous'));

const format =
  ':id :remote-addr :method :url :status :res[content-length]B - :response-time ms user=:user';

module.exports = morgan(format, {
  stream: { write: (message) => logger.http(message.trim()) },
});

Winston's default 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

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):

model RefreshToken {
  id         BigInt    @id @default(autoincrement())
  user_id    BigInt
  token_hash String    @unique
  expires_at DateTime
  revoked_at DateTime?
  created_at DateTime  @default(now())

  user User @relation(fields: [user_id], references: [id])

  @@map("refresh_tokens")
}

model PasswordResetToken {
  id         BigInt    @id @default(autoincrement())
  user_id    BigInt
  token_hash String    @unique
  expires_at DateTime
  used_at    DateTime?
  created_at DateTime  @default(now())

  @@map("password_reset_tokens")
}

5.2 src/middlewares/auth.middleware.js — verify JWT, load user + permissions

const jwt = require('jsonwebtoken');
const ApiError = require('../utils/ApiError');
const env = require('../config/env');
const prisma = require('../config/prisma');

module.exports = async (req, res, next) => {
  try {
    const header = req.headers.authorization;
    if (!header || !header.startsWith('Bearer ')) {
      throw new ApiError(401, 'Authentication token missing');
    }

    const token = header.split(' ')[1];
    const payload = jwt.verify(token, env.JWT_ACCESS_SECRET);

    const user = await prisma.user.findFirst({
      where: { id: BigInt(payload.sub), deleted_at: null },
      include: {
        role: {
          include: { role_permissions: { include: { permission: { include: { module: true } } } } },
        },
      },
    });

    if (!user || !user.is_active || user.status !== 'active') {
      throw new ApiError(401, 'Invalid or inactive user');
    }

    req.user = user;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') return next(new ApiError(401, 'Access token expired'));
    if (err.name === 'JsonWebTokenError') return next(new ApiError(401, 'Invalid access token'));
    next(err instanceof ApiError ? err : new ApiError(401, 'Unauthorized'));
  }
};

5.3 src/middlewares/rbac.middleware.js — module/action permission check

const ApiError = require('../utils/ApiError');

const authorize = (moduleCode, action) => (req, res, next) => {
  const permissions = req.user?.role?.role_permissions || [];

  const allowed = permissions.some(
    (rp) => rp.permission.module.code === moduleCode && rp.permission.action === action
  );

  if (!allowed) {
    return next(new ApiError(403, `Forbidden: requires ${moduleCode}:${action}`));
  }

  next();
};

module.exports = authorize;

Usage in routes:

router.post('/', authenticate, authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create);

Permission actions per module: view, create, edit, delete, approve, export — matching the permissions table defined in the requirements document.

5.4 src/middlewares/validate.middleware.js — Joi request validation

const ApiError = require('../utils/ApiError');

const validate = (schema, source = 'body') => (req, res, next) => {
  const { error, value } = schema.validate(req[source], { abortEarly: false, stripUnknown: true });

  if (error) {
    const errors = error.details.map((d) => ({ field: d.path.join('.'), message: d.message }));
    return next(new ApiError(422, 'Validation failed', errors));
  }

  req[source] = value;
  next();
};

module.exports = validate;

Usage: validate(createVendorSchema) for body (default), validate(listQuerySchema, 'query') for query params.

5.5 src/middlewares/rateLimiter.middleware.js

const rateLimit = require('express-rate-limit');
const env = require('../config/env');

const generalLimiter = rateLimit({
  windowMs: env.RATE_LIMIT_WINDOW_MS,
  max: env.RATE_LIMIT_MAX,
  standardHeaders: true,
  legacyHeaders: false,
  message: { success: false, message: 'Too many requests, please try again later' },
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: env.AUTH_RATE_LIMIT_MAX,
  standardHeaders: true,
  legacyHeaders: false,
  skipSuccessfulRequests: true,
  message: { success: false, message: 'Too many login attempts, please try again later' },
});

module.exports = { generalLimiter, authLimiter };

generalLimiter is applied to all /api routes; authLimiter additionally applied to /api/v1/auth/login and /api/v1/auth/forgot-password.

5.6 Security headers, CORS, HPP, compression

Configured centrally in app.js (Section 8):

  • Helmet: sets X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, 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.

const crypto = require('crypto');
const env = require('../config/env');

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(env.ENCRYPTION_KEY, 'hex'); // 32 bytes

const encrypt = (plainText) => {
  if (plainText === null || plainText === undefined || plainText === '') return null;
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
  const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();
  return Buffer.concat([iv, authTag, encrypted]).toString('base64');
};

const decrypt = (payload) => {
  if (!payload) return null;
  const buffer = Buffer.from(payload, 'base64');
  const iv = buffer.subarray(0, 12);
  const authTag = buffer.subarray(12, 28);
  const encrypted = buffer.subarray(28);
  const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);
  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
};

const blindIndex = (value) => {
  if (!value) return null;
  return crypto
    .createHmac('sha256', env.ENCRYPTION_HMAC_KEY)
    .update(String(value).toLowerCase().trim())
    .digest('hex');
};

module.exports = { encrypt, decrypt, blindIndex };

Apply this in the service layer when writing/reading users.mobile, vendor_bank_details.account_number, etc. — store both the encrypted value and its blind-index column, and query on the blind index.

5.8 File uploads — src/middlewares/upload.middleware.js

const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const ApiError = require('../utils/ApiError');
const env = require('../config/env');

const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, env.UPLOAD_DIR),
  filename: (req, file, cb) => {
    const unique = crypto.randomBytes(16).toString('hex');
    cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
  },
});

const fileFilter = (req, file, cb) => {
  if (!ALLOWED_MIME.includes(file.mimetype)) {
    return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
  }
  cb(null, true);
};

module.exports = multer({
  storage,
  fileFilter,
  limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
});

Notes: filenames are randomized (never trust the original name), the upload directory is not directly web-served — files are streamed through an authenticated, RBAC-checked download endpoint, and MIME type is validated against an allow-list (not the file extension alone).

5.9 Audit logging — src/utils/auditLog.js

const prisma = require('../config/prisma');

module.exports = async ({ tableName, recordId, action, oldValue, newValue, userId, requestId }) => {
  await prisma.auditLog.create({
    data: {
      table_name: tableName,
      record_id: BigInt(recordId),
      action,
      old_value: oldValue ?? undefined,
      new_value: newValue ?? undefined,
      performed_by: userId ? BigInt(userId) : null,
      request_id: requestId,
    },
  });
};

Every create/update/status-change/approve/reject action in every service calls auditLog(...). Prisma model:

model AuditLog {
  id           BigInt   @id @default(autoincrement())
  table_name   String
  record_id    BigInt
  action       String
  old_value    Json?
  new_value    Json?
  performed_by BigInt?
  request_id   String?
  performed_at DateTime @default(now())

  @@map("audit_logs")
  @@index([table_name, record_id])
}

6. Error Handling & Response Format

6.1 src/utils/ApiError.js

class ApiError extends Error {
  constructor(statusCode, message, errors = [], isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.errors = errors;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = ApiError;

6.2 src/utils/ApiResponse.js

class ApiResponse {
  constructor(statusCode, data = null, message = 'Success', meta = null) {
    this.success = statusCode < 400;
    this.message = message;
    if (data !== null) this.data = data;
    if (meta !== null) this.meta = meta;
  }
}

module.exports = ApiResponse;

Standard success envelope:

{
  "success": true,
  "message": "Vendors fetched",
  "data": [ ... ],
  "meta": { "page": 1, "limit": 20, "total": 57 }
}

Standard error envelope:

{
  "success": false,
  "message": "Validation failed",
  "errors": [{ "field": "gstin", "message": "\"gstin\" length must be 15 characters long" }]
}

6.3 src/utils/asyncHandler.js

module.exports = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

6.4 src/middlewares/error.middleware.js — global error handler (registered last)

const ApiError = require('../utils/ApiError');
const logger = require('../config/logger');
const env = require('../config/env');

module.exports = (err, req, res, next) => {
  let statusCode = err.statusCode || 500;
  let message = err.message || 'Internal server error';
  let errors = err.errors || [];

  if (!(err instanceof ApiError)) {
    statusCode = 500;
    message = env.NODE_ENV === 'production' ? 'Internal server error' : err.message;
    errors = [];
  }

  logger.error(message, {
    requestId: req.id,
    statusCode,
    path: req.originalUrl,
    method: req.method,
    user: req.user ? req.user.id.toString() : undefined,
    stack: err.stack,
  });

  res.status(statusCode).json({
    success: false,
    message,
    errors,
    ...(env.NODE_ENV === 'development' && { stack: err.stack }),
  });
};

6.5 src/middlewares/requestId.middleware.js

const { v4: uuidv4 } = require('uuid');

module.exports = (req, res, next) => {
  req.id = req.headers['x-request-id'] || uuidv4();
  res.setHeader('X-Request-Id', req.id);
  next();
};

Every log line and audit log entry carries request_id, so a single request can be traced end-to-end across the access log, error log, and audit trail.


7. App & Server Bootstrap

7.1 src/app.js

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const hpp = require('hpp');
const compression = require('compression');
const swaggerUi = require('swagger-ui-express');

const env = require('./config/env');
const swaggerSpec = require('./config/swagger');
const morganMiddleware = require('./config/morgan');
const requestId = require('./middlewares/requestId.middleware');
const { generalLimiter } = require('./middlewares/rateLimiter.middleware');
const errorMiddleware = require('./middlewares/error.middleware');
const ApiError = require('./utils/ApiError');
const routesV1 = require('./routes/v1');

const app = express();

app.use(helmet());
app.use(cors({ origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()), credentials: true }));
app.use(hpp());
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(requestId);
app.use(morganMiddleware);

app.get('/health', (req, res) => res.json({ success: true, message: 'OK', uptime: process.uptime() }));

app.use('/api', generalLimiter);
app.use('/api/v1', routesV1);

app.use(
  '/api-docs',
  helmet({ contentSecurityPolicy: false }),
  swaggerUi.serve,
  swaggerUi.setup(swaggerSpec)
);

app.use((req, res, next) => next(new ApiError(404, `Route not found: ${req.originalUrl}`)));
app.use(errorMiddleware);

module.exports = app;

7.2 src/server.js

const app = require('./app');
const env = require('./config/env');
const logger = require('./config/logger');
const prisma = require('./config/prisma');

const server = app.listen(env.PORT, () => {
  logger.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});

const shutdown = async (signal) => {
  logger.info(`${signal} received — shutting down gracefully`);
  server.close(async () => {
    await prisma.$disconnect();
    logger.info('HTTP server closed, Prisma disconnected');
    process.exit(0);
  });
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

process.on('unhandledRejection', (reason) => {
  logger.error('Unhandled promise rejection', { reason: reason?.message || reason });
});

process.on('uncaughtException', (err) => {
  logger.error('Uncaught exception — exiting', { error: err.message, stack: err.stack });
  process.exit(1);
});

8. Prisma & Database Conventions

  • Naming: Prisma models in PascalCase (singular), mapped 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:
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:
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

const Joi = require('joi');

const loginSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
});

const refreshSchema = Joi.object({
  refresh_token: Joi.string().required(),
});

module.exports = { loginSchema, refreshSchema };

src/modules/auth/auth.service.js

const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const env = require('../../config/env');

const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');

const issueTokens = async (user) => {
  const accessToken = jwt.sign({ sub: user.id.toString(), role_id: user.role_id?.toString() }, env.JWT_ACCESS_SECRET, {
    expiresIn: env.JWT_ACCESS_EXPIRY,
  });

  const refreshToken = crypto.randomBytes(40).toString('hex');
  await prisma.refreshToken.create({
    data: {
      user_id: user.id,
      token_hash: hashToken(refreshToken),
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  return { accessToken, refreshToken };
};

const login = async (email, password) => {
  const user = await prisma.user.findFirst({ where: { email, deleted_at: null } });
  if (!user) throw new ApiError(401, 'Invalid email or password');

  if (user.locked_until && user.locked_until > new Date()) {
    throw new ApiError(423, 'Account locked due to too many failed attempts. Try again later.');
  }

  const match = await bcrypt.compare(password, user.password_hash);
  if (!match) {
    const attempts = user.failed_login_attempts + 1;
    const lockout = attempts >= env.MAX_LOGIN_ATTEMPTS;
    await prisma.user.update({
      where: { id: user.id },
      data: {
        failed_login_attempts: lockout ? 0 : attempts,
        locked_until: lockout ? new Date(Date.now() + env.LOCKOUT_DURATION_MINUTES * 60 * 1000) : null,
      },
    });
    throw new ApiError(401, 'Invalid email or password');
  }

  if (user.status !== 'active' || !user.is_active) {
    throw new ApiError(403, 'Account is inactive. Contact administrator.');
  }

  await prisma.user.update({
    where: { id: user.id },
    data: { failed_login_attempts: 0, locked_until: null, last_login_at: new Date() },
  });

  return issueTokens(user);
};

const refresh = async (refreshToken) => {
  const tokenHash = hashToken(refreshToken);
  const record = await prisma.refreshToken.findFirst({ where: { token_hash: tokenHash, revoked_at: null } });

  if (!record || record.expires_at < new Date()) {
    throw new ApiError(401, 'Invalid or expired refresh token');
  }

  await prisma.refreshToken.update({ where: { id: record.id }, data: { revoked_at: new Date() } });

  const user = await prisma.user.findUnique({ where: { id: record.user_id } });
  if (!user || !user.is_active) throw new ApiError(401, 'User not found or inactive');

  return issueTokens(user);
};

const logout = async (refreshToken) => {
  const tokenHash = hashToken(refreshToken);
  await prisma.refreshToken.updateMany({
    where: { token_hash: tokenHash, revoked_at: null },
    data: { revoked_at: new Date() },
  });
};

module.exports = { login, refresh, logout };

src/modules/auth/auth.controller.js

const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const authService = require('./auth.service');

const login = asyncHandler(async (req, res) => {
  const tokens = await authService.login(req.body.email, req.body.password);
  res.json(new ApiResponse(200, tokens, 'Login successful'));
});

const refresh = asyncHandler(async (req, res) => {
  const tokens = await authService.refresh(req.body.refresh_token);
  res.json(new ApiResponse(200, tokens, 'Token refreshed'));
});

const logout = asyncHandler(async (req, res) => {
  await authService.logout(req.body.refresh_token);
  res.json(new ApiResponse(200, null, 'Logged out'));
});

module.exports = { login, refresh, logout };

src/modules/auth/auth.routes.js

const express = require('express');
const validate = require('../../middlewares/validate.middleware');
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
const { loginSchema, refreshSchema } = require('./auth.validation');
const controller = require('./auth.controller');

const router = express.Router();

router.post('/login', authLimiter, validate(loginSchema), controller.login);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(refreshSchema), controller.logout);

module.exports = router;

9.3 Full example — Vendors module (transactional CRUD with RBAC + audit)

src/modules/vendors/vendors.validation.js

const Joi = require('joi');

const createVendorSchema = Joi.object({
  vendor_name: Joi.string().max(200).required(),
  vendor_type: Joi.string().valid('RAW_MATERIAL', 'PACKING_MATERIAL', 'ASSET_CAPITAL', 'SERVICE', 'GENERAL').required(),
  gstin: Joi.string().length(15).allow('').optional(),
  pan: Joi.string().length(10).allow('').optional(),
  payment_term_id: Joi.number().integer().optional(),
  credit_period_days: Joi.number().integer().min(0).default(0),
  remarks: Joi.string().allow('').optional(),
});

const updateVendorSchema = createVendorSchema.fork(
  Object.keys(createVendorSchema.describe().keys),
  (s) => s.optional()
);

const statusSchema = Joi.object({
  status: Joi.string().valid('active', 'inactive', 'blacklisted').required(),
});

module.exports = { createVendorSchema, updateVendorSchema, statusSchema };

src/modules/vendors/vendors.service.js

const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { nextDocumentNumber } = require('../../utils/generateCode');

const createVendor = async (data, userId, requestId) => {
  const vendor_code = await nextDocumentNumber('VENDOR');

  const vendor = await prisma.vendor.create({
    data: { ...data, vendor_code, created_by: userId, updated_by: userId },
  });

  await auditLog({ tableName: 'vendors', recordId: vendor.id, action: 'CREATE', newValue: vendor, userId, requestId });
  return vendor;
};

const getVendors = async ({ page, limit, search, status }) => {
  const where = {
    deleted_at: null,
    ...(status && { status }),
    ...(search && { vendor_name: { contains: search, mode: 'insensitive' } }),
  };

  const [data, total] = await Promise.all([
    prisma.vendor.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { created_at: 'desc' } }),
    prisma.vendor.count({ where }),
  ]);

  return { data, total };
};

const getVendorById = async (id) => {
  const vendor = await prisma.vendor.findFirst({ where: { id: BigInt(id), deleted_at: null } });
  if (!vendor) throw new ApiError(404, 'Vendor not found');
  return vendor;
};

const updateVendor = async (id, data, userId, requestId) => {
  const existing = await getVendorById(id);

  const vendor = await prisma.vendor.update({
    where: { id: BigInt(id) },
    data: { ...data, updated_by: userId },
  });

  await auditLog({
    tableName: 'vendors',
    recordId: id,
    action: 'UPDATE',
    oldValue: existing,
    newValue: vendor,
    userId,
    requestId,
  });

  return vendor;
};

const setVendorStatus = async (id, status, userId, requestId) => {
  const existing = await getVendorById(id);

  if (existing.status === 'blacklisted' && status !== 'blacklisted') {
    // Reactivating a blacklisted vendor — flagged for confirmation: should this
    // require an additional approval permission (VENDOR:approve)?
  }

  const vendor = await prisma.vendor.update({ where: { id: BigInt(id) }, data: { status, updated_by: userId } });

  await auditLog({
    tableName: 'vendors',
    recordId: id,
    action: 'STATUS_CHANGE',
    oldValue: { status: existing.status },
    newValue: { status },
    userId,
    requestId,
  });

  return vendor;
};

module.exports = { createVendor, getVendors, getVendorById, updateVendor, setVendorStatus };

src/modules/vendors/vendors.controller.js

const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./vendors.service');

const create = asyncHandler(async (req, res) => {
  const vendor = await service.createVendor(req.body, req.user.id, req.id);
  res.status(201).json(new ApiResponse(201, vendor, 'Vendor created successfully'));
});

const list = asyncHandler(async (req, res) => {
  const page = Number(req.query.page) || 1;
  const limit = Math.min(Number(req.query.limit) || 20, 100);
  const { data, total } = await service.getVendors({ page, limit, search: req.query.search, status: req.query.status });
  res.json(new ApiResponse(200, data, 'Vendors fetched', { page, limit, total }));
});

const getOne = asyncHandler(async (req, res) => {
  const vendor = await service.getVendorById(req.params.id);
  res.json(new ApiResponse(200, vendor, 'Vendor fetched'));
});

const update = asyncHandler(async (req, res) => {
  const vendor = await service.updateVendor(req.params.id, req.body, req.user.id, req.id);
  res.json(new ApiResponse(200, vendor, 'Vendor updated successfully'));
});

const changeStatus = asyncHandler(async (req, res) => {
  const vendor = await service.setVendorStatus(req.params.id, req.body.status, req.user.id, req.id);
  res.json(new ApiResponse(200, vendor, 'Vendor status updated'));
});

module.exports = { create, list, getOne, update, changeStatus };

src/modules/vendors/vendors.routes.js

const express = require('express');
const authenticate = require('../../middlewares/auth.middleware');
const authorize = require('../../middlewares/rbac.middleware');
const validate = require('../../middlewares/validate.middleware');
const controller = require('./vendors.controller');
const { createVendorSchema, updateVendorSchema, statusSchema } = require('./vendors.validation');

const router = express.Router();

router.use(authenticate);

router.get('/', authorize('VENDOR', 'view'), controller.list);
router.get('/:id', authorize('VENDOR', 'view'), controller.getOne);
router.post('/', authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create);
router.put('/:id', authorize('VENDOR', 'edit'), validate(updateVendorSchema), controller.update);
router.patch('/:id/status', authorize('VENDOR', 'edit'), validate(statusSchema), controller.changeStatus);

module.exports = router;

9.4 Masters module pattern

Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, 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:

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:

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):
// 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.
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/tests/**/*.test.js'],
  setupFiles: ['dotenv/config'],
};

13. Code Quality

.eslintrc.json:

{
  "env": { "node": true, "es2022": true, "jest": true },
  "extends": ["eslint:recommended", "plugin:prettier/recommended"],
  "parserOptions": { "ecmaVersion": 2022, "sourceType": "script" },
  "rules": {
    "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
    "no-console": "warn"
  }
}

.prettierrc:

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "es5",
  "printWidth": 100
}

Husky + lint-staged (package.json):

{
  "lint-staged": {
    "src/**/*.js": ["eslint --fix", "prettier --write"]
  }
}

14. API Documentation — Swagger

src/config/swagger.js:

const swaggerJsdoc = require('swagger-jsdoc');

module.exports = swaggerJsdoc({
  definition: {
    openapi: '3.0.0',
    info: { title: 'ERP API', version: '1.0.0', description: 'Phase 1 — PO, GRN, Vendor, Assets, Masters, Users & RBAC' },
    servers: [{ url: '/api/v1' }],
    components: {
      securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
    },
    security: [{ bearerAuth: [] }],
  },
  apis: ['./src/modules/**/*.routes.js'],
});

Each route file carries @swagger JSDoc blocks above each route definition describing path, params, request body schema, and responses.


15. Docker

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npx prisma generate
EXPOSE 3000
CMD ["node", "src/server.js"]

docker-compose.yml:

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file: .env
    depends_on:
      - postgres
    volumes:
      - ./uploads:/app/uploads
      - ./logs:/app/logs

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: erp_user
      POSTGRES_PASSWORD: erp_password
      POSTGRES_DB: erp_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

16. package.json — dependencies & scripts

{
  "name": "erp-backend",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "lint": "eslint src --ext .js",
    "lint:fix": "eslint src --ext .js --fix",
    "format": "prettier --write \"src/**/*.js\"",
    "test": "jest --runInBand",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev",
    "prisma:deploy": "prisma migrate deploy",
    "prisma:seed": "node prisma/seed.js",
    "prepare": "husky install"
  },
  "dependencies": {
    "@prisma/client": "^5.x",
    "express": "^4.x",
    "joi": "^17.x",
    "jsonwebtoken": "^9.x",
    "bcrypt": "^5.x",
    "helmet": "^7.x",
    "cors": "^2.x",
    "hpp": "^0.2.x",
    "compression": "^1.x",
    "express-rate-limit": "^7.x",
    "winston": "^3.x",
    "winston-daily-rotate-file": "^5.x",
    "morgan": "^1.x",
    "multer": "^1.x",
    "uuid": "^9.x",
    "dotenv": "^16.x",
    "swagger-jsdoc": "^6.x",
    "swagger-ui-express": "^5.x"
  },
  "devDependencies": {
    "prisma": "^5.x",
    "nodemon": "^3.x",
    "eslint": "^8.x",
    "eslint-config-prettier": "^9.x",
    "eslint-plugin-prettier": "^5.x",
    "prettier": "^3.x",
    "jest": "^29.x",
    "supertest": "^6.x",
    "husky": "^9.x",
    "lint-staged": "^15.x"
  }
}

17. Security Checklist (OWASP-mapped)

Concern Mitigation in this setup
SQL injection Prisma parameterized queries everywhere; never raw string-concatenated SQL
Broken authentication bcrypt password hashing, short-lived JWT access tokens, rotating hashed refresh tokens, account lockout after repeated failures
Broken access control authenticate + authorize(module, action) on every protected route; soft-deleted/inactive users rejected at auth
Sensitive data exposure AES-256-GCM field encryption + HMAC blind index for PII; secrets only in .env (never committed); Winston redacts password/token fields
Security misconfiguration Helmet security headers, env validated on boot (fails fast if misconfigured), no default credentials in seed beyond a documented bootstrap admin
Brute force / DoS express-rate-limit global + stricter limiter on /auth/login
Cross-site scripting (XSS) Joi input validation/sanitization on every endpoint; JSON-only API (no server-rendered HTML); Flutter client responsible for output encoding
CSRF Not applicable for header-based Bearer JWT (no cookies); if a web admin later uses httpOnly cookies for refresh tokens, add csurf / double-submit cookie pattern
Insecure file upload Multer MIME allow-list, random filenames, size limit, files served only via authenticated/RBAC-checked endpoints
Insufficient logging & monitoring Winston (console + daily-rotated error/combined logs), Morgan HTTP access logs, audit_logs table for all data-changing actions, X-Request-Id correlation across logs
Dependency vulnerabilities Run npm audit in CI; keep package-lock.json committed

18. Build Order (for Cursor)

  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.