Initial project setup

This commit is contained in:
Gowtham M 2026-06-17 11:30:11 +05:30
commit 4a3f4d4b06
110 changed files with 16106 additions and 0 deletions

View File

@ -0,0 +1,39 @@
---
description: ERP backend core stack, security, and API conventions
alwaysApply: true
---
# ERP Backend — Core Conventions
Stack: Node.js 20+ · Express (CommonJS) · PostgreSQL 15+ · Prisma · Joi · Winston
## Non-negotiables
- CommonJS only (`require` / `module.exports`) — no ESM
- Validate all env vars on boot via `src/config/env.js`; refuse to start if invalid
- Never commit `.env`; only `.env.example`
- All protected routes: `authenticate` → `authorize(module, action)` → `validate(schema)` → controller
- Soft delete: filter `deleted_at: null`; set `deleted_at = now()` instead of hard DELETE
- Every mutation calls `auditLog({ tableName, recordId, action, oldValue, newValue, userId, requestId })`
- Controllers return `ApiResponse`; throw `ApiError` for failures — no Prisma or business logic in controllers
- Serialize `BigInt` as strings in JSON (`BigInt.prototype.toJSON`)
## Response envelope
```json
{ "success": true, "message": "...", "data": {}, "meta": { "page": 1, "limit": 20, "total": 57 } }
```
## Security
- JWT access (15m) in Bearer header; refresh tokens hashed (SHA-256) in DB, rotated on refresh
- bcrypt passwords; account lockout after failed attempts
- AES-256-GCM + HMAC blind index for PII (mobile, bank accounts) — encrypt in service layer
- Rate limit `/api` globally; stricter limit on auth routes
- File uploads: MIME allow-list, random filenames, serve via authenticated RBAC endpoint only
## API
- Routes under `/api/v1/`; pagination `?page=1&limit=20` (max 100); dates ISO 8601 UTC
- RBAC actions: `view`, `create`, `edit`, `delete`, `approve`, `export`
- Reference: `BACKEND_SETUP.md`, task checklist: `BACKEND_TASKS.md`

View File

@ -0,0 +1,43 @@
---
description: HMVC module pattern for ERP backend src/modules
globs: src/modules/**/*.js
alwaysApply: false
---
# HMVC Module Pattern
Each module is self-contained with this file set:
| File | Role |
|------|------|
| `*.routes.js` | HTTP verbs + middleware chain only |
| `*.controller.js` | Parse request, call service, shape `ApiResponse` via `asyncHandler` |
| `*.service.js` | Business logic, Prisma calls, `auditLog` |
| `*.validation.js` | Joi schemas (body default; `'query'` for list filters) |
| `*.repository.js` | **Complex modules only** (PO, GRN, Assets) — multi-table `prisma.$transaction` |
## Route middleware order
```js
router.post('/', authenticate, authorize('VENDOR', 'create'), validate(createSchema), controller.create);
```
## Service rules
- Simple masters/CRUD: call Prisma directly from service
- Auto codes: `nextDocumentNumber('VENDOR')` inside a transaction
- Pass `req.user.id` and `req.id` (request ID) into service for audit trails
- Filter all reads with `deleted_at: null`
## Masters
- RBAC module code: `MASTERS`
- UOM is the template — replicate four-file pattern for other masters
- Aggregate sub-routers in `src/modules/masters/index.js`
## Do not
- Put Prisma calls or business logic in controllers
- Skip `authorize()` on protected endpoints
- Hard-delete transactional records
- Return raw refresh tokens or passwords in API responses

View File

@ -0,0 +1,41 @@
---
description: Prisma schema and database conventions for ERP backend
globs: prisma/**/*
alwaysApply: false
---
# Prisma & Database Conventions
## Naming
- Models: `PascalCase` singular (e.g. `Vendor`)
- Fields: `snake_case` (match requirements doc — no camelCase mapping)
- Tables: `snake_case` plural via `@@map("vendors")`
## Required columns (every business table)
```
is_active, created_by, updated_by, created_at, updated_at, deleted_at
```
## Patterns
- Primary keys: `BigInt @id @default(autoincrement())`
- Soft delete everywhere on transactional tables
- Document numbering via `document_series` + atomic increment in `generateCode.js`
- Seed idempotently with `upsert` — modules, permissions, roles, bootstrap Super Admin
## Migration workflow
```bash
npx prisma migrate dev --name <description>
npx prisma generate
node prisma/seed.js
```
## When adding a new table
1. Add model with common columns + `@@map`
2. Create migration
3. Update seed if new module/permissions needed
4. Add service with `deleted_at: null` filters and audit logging

42
.env.example Normal file
View File

@ -0,0 +1,42 @@
NODE_ENV=development
PORT=3000
# PostgreSQL
DB_HOST=demo.venbait.in
DB_PORT=5432
DB_NAME=bharaterp
DB_USER=bharaterpdevdbuser
DB_PASSWORD=devdbuser@bharaterp
DATABASE_URL=postgresql://bharaterpdevdbuser:devdbuser%40bharaterp@demo.venbait.in:5432/bharaterp?schema=public
# JWT
JWT_ACCESS_SECRET=replace_with_strong_random_value_min_32_chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_SECRET=replace_with_another_strong_random_value_min_32_chars
JWT_REFRESH_EXPIRY=7d
# Password hashing
BCRYPT_SALT_ROUNDS=12
# Encryption
ENCRYPTION_KEY=replace_with_64_char_hex_string
ENCRYPTION_HMAC_KEY=replace_with_strong_hmac_key
# CORS
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
# Uploads
UPLOAD_DIR=uploads
MAX_FILE_SIZE_MB=5
# Account lockout
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30

9
.eslintrc.json Normal file
View File

@ -0,0 +1,9 @@
{
"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"
}
}

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
.env
logs/
uploads/
coverage/
.DS_Store

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100
}

1518
BACKEND_SETUP.md Normal file

File diff suppressed because it is too large Load Diff

186
BACKEND_TASKS.md Normal file
View File

@ -0,0 +1,186 @@
# ERP Backend — Development Tasks (Phase 1)
Reference: [BACKEND_SETUP.md](./BACKEND_SETUP.md)
Use this checklist when building or extending the backend. Follow the build order in Section 1.
---
## 1. Project Scaffold
- [x] Create `package.json` with dependencies and scripts from the spec
- [x] Install dependencies (`npm install`)
- [x] Add `.env.example` (never commit `.env`)
- [x] Configure ESLint, Prettier, Husky, lint-staged
- [x] Add `jest.config.js` with `dotenv/config` setup file
---
## 2. Database & Prisma
- [x] Define `prisma/schema.prisma`:
- Auth: `User`, `Role`, `Module`, `Permission`, `RolePermission`, `RefreshToken`, `PasswordResetToken`
- System: `AuditLog`, `DocumentSeries`
- Business: Vendor (+ sub-tables), PurchaseOrder (+ items), GRN (+ items), Asset (+ transfers), all masters
- [x] Use `PascalCase` models, `snake_case` fields, `@@map` to plural table names
- [ ] Add common columns on every business table: `is_active`, `created_by`, `updated_by`, `created_at`, `updated_at`, `deleted_at`
- [ ] Run `npx prisma migrate dev --name init`
- [x] Run `npx prisma generate`
- [ ] Implement idempotent `prisma/seed.js` (modules → permissions → roles → role_permissions → Super Admin user)
### Seed requirements
| Step | Content |
|------|---------|
| Modules | `USERS`, `ROLES`, `MASTERS`, `VENDOR`, `PURCHASE_ORDER`, `GRN`, `ASSET` |
| Permissions | Each module × `view`, `create`, `edit`, `delete`, `approve`, `export` (skip where N/A) |
| Roles | Super Admin, Admin, Purchase Manager, Store Manager, Accounts, Asset Manager |
| Bootstrap user | Super Admin, bcrypt-hashed password, `status = active` |
---
## 3. Core Infrastructure
- [x] `src/config/env.js` — Joi validation; app must refuse to boot on invalid/missing env
- [x] `src/config/logger.js` — Winston + daily rotate; redact sensitive keys
- [x] `src/config/morgan.js` — HTTP logs into Winston with request ID + user
- [x] `src/config/prisma.js` — singleton client with query/error logging
- [x] `src/config/swagger.js` — OpenAPI 3.0 from route JSDoc
- [x] `src/utils/` — ApiError, ApiResponse, asyncHandler, encryption, auditLog, pagination, generateCode
- [x] `src/middlewares/` — requestId, error, validate, rateLimiter, auth, rbac, upload
- [x] `src/app.js` — Helmet, CORS, HPP, compression, rate limit, routes, Swagger, 404, error handler
- [x] `src/server.js` — graceful shutdown, BigInt JSON serializer, unhandled rejection/exception handlers
- [ ] Verify `GET /health` responds
---
## 4. Modules (HMVC Pattern)
Build in this order. Each module: `*.routes.js``*.controller.js``*.service.js``*.validation.js` (+ `*.repository.js` for complex modules).
| # | Module | Notes |
|---|--------|-------|
| 8 | **auth** | login, refresh, logout; rotating hashed refresh tokens; account lockout |
| 9 | **masters** | UOM first as template, then replicate 13 sub-masters under `masters/index.js` |
| 10 | **vendors** | CRUD + addresses/contacts/bank-details; encrypt bank account numbers |
| 11 | **purchase-orders** | Header + line items; repository for transactions; submit/approve/reject/amend/cancel |
| 12 | **grn** | Receipt + PO status recalc + asset auto-creation in one transaction |
| 13 | **assets** | CRUD + transfer + transfer history |
| — | **users** | CRUD, RBAC: `USERS` |
| — | **roles** | CRUD + permission assignment, RBAC: `ROLES` |
Current progress:
- [x] `auth` module scaffold (`login`, `refresh`, `logout`) implemented
- [x] `masters/uom` module implemented as template (CRUD + RBAC + validation + audit log)
- [x] Replicated masters CRUD modules: `item-categories`, `brands`, `gst-rates`, `payment-terms`, `delivery-terms`, `asset-categories`, `departments`, `designations`, `document-series`
- [x] Pending masters now completed: `item-subcategories`, `items`, `warehouses`, `plants`
### Layer rules
| Layer | Responsibility |
|-------|----------------|
| Routes | HTTP mapping + middleware chain (`authenticate` → `authorize``validate` → controller) |
| Controller | Parse request, call service, return `ApiResponse` — no business logic, no Prisma |
| Service | Business logic, Prisma (or repository), `auditLog` on every mutation |
| Repository | Multi-table `prisma.$transaction` only (PO, GRN, Assets) |
| Validation | Joi schemas for body/query |
---
## 5. API Routes (Phase 1)
```
/api/v1/auth/{login,refresh,logout}
/api/v1/users
/api/v1/roles
/api/v1/masters/{uom,item-categories,item-subcategories,items,brands,gst-rates,warehouses,payment-terms,delivery-terms,asset-categories,departments,designations,plants,document-series}
/api/v1/vendors (+ /addresses, /contacts, /bank-details)
/api/v1/purchase-orders (+ /submit, /approve, /reject, /amend, /cancel, /pdf)
/api/v1/grn (+ /cancel, /pdf)
/api/v1/assets (+ /transfer, /transfer-history)
```
---
## 6. Security Tasks
- [x] JWT access tokens (15m) in `Authorization: Bearer` header
- [x] Refresh tokens: opaque, SHA-256 hashed in DB, rotated on each refresh
- [x] bcrypt password hashing (`BCRYPT_SALT_ROUNDS=12`)
- [x] Account lockout after `MAX_LOGIN_ATTEMPTS` failures
- [x] `authenticate` + `authorize(module, action)` on every protected route
- [x] AES-256-GCM field encryption + HMAC blind index for searchable PII
- [x] Rate limiting: global on `/api`, stricter on `/auth/login` and `/auth/forgot-password`
- [x] Helmet, CORS (no `*` in production), HPP, compression
- [x] File uploads: MIME allow-list, random filenames, RBAC-protected download (not direct web serve)
- [ ] `auditLog()` on every create/update/status-change/approve/reject
- [x] `X-Request-Id` on every request for log correlation
### RBAC permission actions
`view`, `create`, `edit`, `delete`, `approve`, `export`
---
## 7. API Conventions
- Versioning: `/api/v1/...`
- Pagination: `?page=1&limit=20` (cap limit at 100); response `meta: { page, limit, total }`
- Filtering: `?status=...&search=...` per module
- Sorting: `?sort=-created_at` (`-` = descending)
- IDs: `BigInt` serialized as strings in JSON
- Dates: ISO 8601 UTC
- Soft delete: set `deleted_at`, never hard-delete transactional records
- Response envelope: `{ success, message, data?, meta?, errors? }`
---
## 8. Testing
- [ ] Jest + Supertest against `app.js` (no live server)
- [ ] Separate test DB via `.env.test` + `prisma migrate deploy`
- [ ] Mirror structure: `tests/modules/<module>/<module>.test.js`
### Minimum Phase 1 coverage
- [ ] Auth: login, refresh, lockout
- [ ] RBAC: allowed vs forbidden
- [ ] Vendor: CRUD + status transitions
- [ ] PO: status lifecycle
- [ ] GRN: partial receipt → PO status recalculation
---
## 9. Documentation & DevOps
- [ ] Add `@swagger` JSDoc blocks to every route file
- [ ] `Dockerfile` (Node 20 Alpine)
- [ ] `docker-compose.yml` (API + Postgres 15)
- [ ] Verify `docker-compose up` boots API + Postgres
- [ ] Run `npm audit` in CI
---
## 10. Future Development Guidelines
When adding a new module or endpoint:
1. Add Prisma model with common columns + soft delete
2. Add module/permissions to seed (if new domain)
3. Create the four-file HMVC module (five if multi-table transactions)
4. Register routes in `src/routes/v1/index.js`
5. Apply middleware chain: `authenticate``authorize(MODULE, action)``validate(schema)` → controller
6. Call `auditLog()` in service for all mutations
7. Use `nextDocumentNumber()` for auto-generated codes
8. Encrypt PII at service layer with blind index for search
9. Add Swagger JSDoc + Jest tests
10. Breaking API changes → new version (`/api/v2`)
### Complex transaction modules
Use `*.repository.js` with `prisma.$transaction` when a single operation touches multiple tables (e.g. PO header + items, GRN + PO qty update + asset creation).
### Masters replication
Copy the UOM module pattern for new masters: simple name/code fields, RBAC code `MASTERS`, no document numbering.

32
SETUP_STATUS.md Normal file
View File

@ -0,0 +1,32 @@
# Setup Status
Initial setup has been started with your DB config:
- Host: `demo.venbait.in`
- Port: `5432`
- Database: `bharaterp`
- User: `bharaterpdevdbuser`
## What is ready
- Base Node/Express scaffold in `src/`
- Environment files (`.env`, `.env.example`)
- Prisma datasource in `prisma/schema.prisma`
- Database bootstrap script: `scripts/setup-db.sh`
## Run DB setup
```bash
cd /home/smart/Documents/ERP_BE
./scripts/setup-db.sh /home/smart/Downloads/erp_phase1_ddl.sql
```
The script:
1. Creates `bharaterp` if it does not exist
2. Executes the provided Phase 1 DDL on that database
## Next steps
1. Install npm dependencies (if network allows)
2. Generate Prisma client: `npm run prisma:generate`
3. Start API: `npm run dev`

5
jest.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
setupFiles: ['dotenv/config'],
};

4
nodemon.json Normal file
View File

@ -0,0 +1,4 @@
{
"watch": ["src"],
"ext": "js,json,yaml,yml"
}

7737
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "erp-backend",
"version": "1.0.0",
"description": "ERP backend (Phase 1)",
"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"
},
"lint-staged": {
"src/**/*.js": [
"eslint --fix",
"prettier --write"
]
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcrypt": "^5.1.1",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"helmet": "^7.2.0",
"hpp": "^0.2.3",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^1.4.5-lts.2",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.18.3",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.5.2",
"nodemon": "^3.1.10",
"prettier": "^3.6.2",
"prisma": "^5.22.0",
"supertest": "^6.3.4"
},
"keywords": [],
"author": "",
"license": "ISC"
}

891
prisma/schema.prisma Normal file
View File

@ -0,0 +1,891 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model asset_attachments {
id BigInt @id @default(autoincrement())
asset_id BigInt
attachment_type String @default("DOCUMENT") @db.VarChar(50)
file_name String @db.VarChar(255)
file_path String @db.VarChar(500)
file_type String? @db.VarChar(100)
file_size Int?
uploaded_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model asset_categories {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(150)
code_prefix String @unique @db.VarChar(10)
default_useful_life_years Int?
default_depreciation_method String? @db.VarChar(20)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_asset_categories_created_byTousers users? @relation("asset_categories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_asset_categories_updated_byTousers users? @relation("asset_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
assets assets[]
}
model asset_transfers {
id BigInt @id @default(autoincrement())
asset_id BigInt
transfer_date DateTime @db.Date
from_plant_id BigInt?
to_plant_id BigInt?
from_department_id BigInt?
to_department_id BigInt?
from_user_id BigInt?
to_user_id BigInt?
from_warehouse_id BigInt?
to_warehouse_id BigInt?
reason String?
transferred_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
departments_asset_transfers_from_department_idTodepartments departments? @relation("asset_transfers_from_department_idTodepartments", fields: [from_department_id], references: [id], onUpdate: NoAction)
plants_asset_transfers_from_plant_idToplants plants? @relation("asset_transfers_from_plant_idToplants", fields: [from_plant_id], references: [id], onUpdate: NoAction)
users_asset_transfers_from_user_idTousers users? @relation("asset_transfers_from_user_idTousers", fields: [from_user_id], references: [id], onUpdate: NoAction)
warehouses_asset_transfers_from_warehouse_idTowarehouses warehouses? @relation("asset_transfers_from_warehouse_idTowarehouses", fields: [from_warehouse_id], references: [id], onUpdate: NoAction)
departments_asset_transfers_to_department_idTodepartments departments? @relation("asset_transfers_to_department_idTodepartments", fields: [to_department_id], references: [id], onUpdate: NoAction)
plants_asset_transfers_to_plant_idToplants plants? @relation("asset_transfers_to_plant_idToplants", fields: [to_plant_id], references: [id], onUpdate: NoAction)
users_asset_transfers_to_user_idTousers users? @relation("asset_transfers_to_user_idTousers", fields: [to_user_id], references: [id], onUpdate: NoAction)
warehouses_asset_transfers_to_warehouse_idTowarehouses warehouses? @relation("asset_transfers_to_warehouse_idTowarehouses", fields: [to_warehouse_id], references: [id], onUpdate: NoAction)
users_asset_transfers_transferred_byTousers users? @relation("asset_transfers_transferred_byTousers", fields: [transferred_by], references: [id], onUpdate: NoAction)
@@index([asset_id], map: "idx_asset_transfers_asset_id")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model assets {
id BigInt @id @default(autoincrement())
asset_code String @unique @db.VarChar(30)
asset_name String @db.VarChar(200)
asset_category_id BigInt
brand_model String? @db.VarChar(200)
manufacturer String? @db.VarChar(200)
serial_number String? @db.VarChar(100)
part_number String? @db.VarChar(100)
plant_id BigInt
department_id BigInt?
warehouse_id BigInt?
location_detail String? @db.VarChar(200)
assigned_to_user_id BigInt?
vendor_id BigInt?
po_id BigInt?
grn_id BigInt?
grn_item_id BigInt?
purchase_date DateTime? @db.Date
purchase_cost Decimal @default(0) @db.Decimal(15, 4)
useful_life_years Int?
depreciation_method String? @db.VarChar(10)
salvage_value Decimal? @default(0) @db.Decimal(15, 4)
warranty_expiry_date DateTime? @db.Date
amc_start_date DateTime? @db.Date
amc_end_date DateTime? @db.Date
amc_vendor_id BigInt?
insurance_policy_no String? @db.VarChar(100)
insurance_expiry_date DateTime? @db.Date
condition String @default("NEW") @db.VarChar(20)
status String @default("IN_USE") @db.VarChar(30)
qr_code_value String? @db.VarChar(100)
disposal_date DateTime? @db.Date
disposal_reason String?
disposal_value Decimal? @db.Decimal(15, 4)
remarks String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
asset_attachments asset_attachments[]
asset_transfers asset_transfers[]
vendors_assets_amc_vendor_idTovendors vendors? @relation("assets_amc_vendor_idTovendors", fields: [amc_vendor_id], references: [id], onUpdate: NoAction)
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction)
grn grn? @relation(fields: [grn_id], references: [id], onUpdate: NoAction)
grn_items grn_items? @relation(fields: [grn_item_id], references: [id], onUpdate: NoAction)
plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders? @relation(fields: [po_id], references: [id], onUpdate: NoAction)
users_assets_updated_byTousers users? @relation("assets_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors_assets_vendor_idTovendors vendors? @relation("assets_vendor_idTovendors", fields: [vendor_id], references: [id], onUpdate: NoAction)
warehouses warehouses? @relation(fields: [warehouse_id], references: [id], onUpdate: NoAction)
@@index([asset_category_id], map: "idx_assets_category_id")
@@index([department_id], map: "idx_assets_dept_id")
@@index([plant_id], map: "idx_assets_plant_id")
}
model audit_logs {
id BigInt @id @default(autoincrement())
table_name String @db.VarChar(100)
record_id BigInt
action String @db.VarChar(50)
old_value Json?
new_value Json?
performed_by BigInt?
request_id String? @db.VarChar(50)
performed_at DateTime @default(now()) @db.Timestamptz(6)
users users? @relation(fields: [performed_by], references: [id], onUpdate: NoAction)
@@index([performed_at], map: "idx_audit_logs_performed_at")
@@index([performed_by], map: "idx_audit_logs_performed_by")
@@index([table_name, record_id], map: "idx_audit_logs_table_record")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model brands {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(150)
brand_type String @default("OWN") @db.VarChar(20)
contact_person String? @db.VarChar(150)
phone String? @db.VarChar(15)
email String? @db.VarChar(150)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_brands_created_byTousers users? @relation("brands_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_brands_updated_byTousers users? @relation("brands_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
items items[]
purchase_orders purchase_orders[]
}
model delivery_terms {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(100)
description String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_delivery_terms_created_byTousers users? @relation("delivery_terms_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_delivery_terms_updated_byTousers users? @relation("delivery_terms_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders[]
}
model departments {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
asset_transfers_asset_transfers_from_department_idTodepartments asset_transfers[] @relation("asset_transfers_from_department_idTodepartments")
asset_transfers_asset_transfers_to_department_idTodepartments asset_transfers[] @relation("asset_transfers_to_department_idTodepartments")
assets assets[]
users users[]
}
model designations {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users users[]
}
model document_series {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50)
prefix String @db.VarChar(30)
current_number Int @default(0)
padding Int @default(5)
description String? @db.VarChar(200)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users_document_series_created_byTousers users? @relation("document_series_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_document_series_updated_byTousers users? @relation("document_series_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model grn {
id BigInt @id @default(autoincrement())
grn_number String @unique @db.VarChar(30)
grn_date DateTime @db.Date
po_id BigInt
vendor_id BigInt
warehouse_id BigInt
vendor_invoice_no String? @db.VarChar(100)
vendor_invoice_date DateTime? @db.Date
vendor_invoice_amount Decimal? @db.Decimal(15, 4)
vehicle_no String? @db.VarChar(30)
lr_no String? @db.VarChar(50)
lr_date DateTime? @db.Date
status String @default("POSTED") @db.VarChar(20)
cancellation_reason String?
cancelled_by BigInt?
cancelled_at DateTime? @db.Timestamptz(6)
received_by BigInt?
quality_checked_by BigInt?
remarks String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
assets assets[]
users_grn_cancelled_byTousers users? @relation("grn_cancelled_byTousers", fields: [cancelled_by], references: [id], onUpdate: NoAction)
users_grn_created_byTousers users? @relation("grn_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders @relation(fields: [po_id], references: [id], onUpdate: NoAction)
users_grn_quality_checked_byTousers users? @relation("grn_quality_checked_byTousers", fields: [quality_checked_by], references: [id], onUpdate: NoAction)
users_grn_received_byTousers users? @relation("grn_received_byTousers", fields: [received_by], references: [id], onUpdate: NoAction)
users_grn_updated_byTousers users? @relation("grn_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction)
warehouses warehouses @relation(fields: [warehouse_id], references: [id], onUpdate: NoAction)
grn_attachments grn_attachments[]
grn_items grn_items[]
@@index([grn_date], map: "idx_grn_grn_date")
@@index([po_id], map: "idx_grn_po_id")
@@index([vendor_id], map: "idx_grn_vendor_id")
}
model grn_attachments {
id BigInt @id @default(autoincrement())
grn_id BigInt
file_name String @db.VarChar(255)
file_path String @db.VarChar(500)
file_type String? @db.VarChar(100)
file_size Int?
uploaded_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
grn grn @relation(fields: [grn_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model grn_items {
id BigInt @id @default(autoincrement())
grn_id BigInt
po_item_id BigInt
item_id BigInt
line_no Int
ordered_qty Decimal @db.Decimal(15, 4)
previously_received_qty Decimal @default(0) @db.Decimal(15, 4)
current_qty Decimal @db.Decimal(15, 4)
accepted_qty Decimal @db.Decimal(15, 4)
rejected_qty Decimal @default(0) @db.Decimal(15, 4)
rejection_reason String?
uom_id BigInt
rate Decimal @db.Decimal(15, 4)
batch_no String? @db.VarChar(100)
mfg_date DateTime? @db.Date
expiry_date DateTime? @db.Date
storage_location String? @db.VarChar(100)
remarks String?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
assets assets[]
grn grn @relation(fields: [grn_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
items items @relation(fields: [item_id], references: [id], onUpdate: NoAction)
purchase_order_items purchase_order_items @relation(fields: [po_item_id], references: [id], onUpdate: NoAction)
uom uom @relation(fields: [uom_id], references: [id], onUpdate: NoAction)
@@unique([grn_id, line_no])
@@index([grn_id], map: "idx_grn_items_grn_id")
@@index([item_id], map: "idx_grn_items_item_id")
}
model gst_rates {
id BigInt @id @default(autoincrement())
rate_pct Decimal @unique @db.Decimal(5, 2)
description String? @db.VarChar(100)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
items items[]
purchase_order_items purchase_order_items[]
}
model hsn_codes {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(20)
description String?
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
items items[]
purchase_order_items purchase_order_items[]
}
model item_categories {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(100)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_item_categories_created_byTousers users? @relation("item_categories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_item_categories_updated_byTousers users? @relation("item_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories[]
items items[]
}
model item_subcategories {
id BigInt @id @default(autoincrement())
item_category_id BigInt
code String @unique @db.VarChar(30)
name String @db.VarChar(100)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_item_subcategories_created_byTousers users? @relation("item_subcategories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
users_item_subcategories_updated_byTousers users? @relation("item_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
items items[]
}
model items {
id BigInt @id @default(autoincrement())
item_code String @unique @db.VarChar(50)
item_name String @db.VarChar(200)
item_category_id BigInt
item_subcategory_id BigInt?
uom_id BigInt
hsn_code_id BigInt?
gst_rate_id BigInt?
brand_id BigInt?
is_asset_item Boolean @default(false)
description String?
specification String?
min_order_qty Decimal? @db.Decimal(15, 4)
reorder_level Decimal? @db.Decimal(15, 4)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
grn_items grn_items[]
brands brands? @relation(fields: [brand_id], references: [id], onUpdate: NoAction)
users_items_created_byTousers users? @relation("items_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
gst_rates gst_rates? @relation(fields: [gst_rate_id], references: [id], onUpdate: NoAction)
hsn_codes hsn_codes? @relation(fields: [hsn_code_id], references: [id], onUpdate: NoAction)
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories? @relation(fields: [item_subcategory_id], references: [id], onUpdate: NoAction)
uom uom @relation(fields: [uom_id], references: [id], onUpdate: NoAction)
users_items_updated_byTousers users? @relation("items_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
purchase_order_items purchase_order_items[]
vendor_item_mapping vendor_item_mapping[]
@@index([item_category_id], map: "idx_items_category")
@@index([item_name(ops: raw("gin_trgm_ops"))], map: "idx_items_name_trgm", type: Gin)
}
model modules {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(50)
name String @db.VarChar(100)
parent_module_id BigInt?
sort_order Int @default(0)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
modules modules? @relation("modulesTomodules", fields: [parent_module_id], references: [id], onUpdate: NoAction)
other_modules modules[] @relation("modulesTomodules")
permissions permissions[]
}
model password_reset_tokens {
id BigInt @id @default(autoincrement())
user_id BigInt
token_hash String @unique @db.VarChar(64)
expires_at DateTime @db.Timestamptz(6)
used_at DateTime? @db.Timestamptz(6)
created_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
}
model payment_terms {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(100)
credit_days Int @default(0)
description String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_payment_terms_created_byTousers users? @relation("payment_terms_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_payment_terms_updated_byTousers users? @relation("payment_terms_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders[]
vendors vendors[]
}
model permissions {
id BigInt @id @default(autoincrement())
module_id BigInt
action String @db.VarChar(50)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
modules modules @relation(fields: [module_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
role_permissions role_permissions[]
@@unique([module_id, action])
}
model plants {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(150)
gstin String? @db.VarChar(15)
address String?
city String? @db.VarChar(100)
state String? @db.VarChar(100)
pincode String? @db.VarChar(10)
phone String? @db.VarChar(15)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
asset_transfers_asset_transfers_from_plant_idToplants asset_transfers[] @relation("asset_transfers_from_plant_idToplants")
asset_transfers_asset_transfers_to_plant_idToplants asset_transfers[] @relation("asset_transfers_to_plant_idToplants")
assets assets[]
users_plants_created_byTousers users? @relation("plants_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_plants_created_by")
users_plants_updated_byTousers users? @relation("plants_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_plants_updated_by")
purchase_orders purchase_orders[]
users_users_plant_idToplants users[] @relation("users_plant_idToplants")
warehouses warehouses[]
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model po_approvals {
id BigInt @id @default(autoincrement())
po_id BigInt
approval_level Int
approver_role_id BigInt?
approver_user_id BigInt?
status String @default("PENDING") @db.VarChar(20)
remarks String?
acted_at DateTime? @db.Timestamptz(6)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
roles roles? @relation(fields: [approver_role_id], references: [id], onUpdate: NoAction)
users users? @relation(fields: [approver_user_id], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders @relation(fields: [po_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([po_id], map: "idx_po_approvals_po_id")
}
model po_attachments {
id BigInt @id @default(autoincrement())
po_id BigInt
file_name String @db.VarChar(255)
file_path String @db.VarChar(500)
file_type String? @db.VarChar(100)
file_size Int?
uploaded_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
purchase_orders purchase_orders @relation(fields: [po_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users users? @relation(fields: [uploaded_by], references: [id], onUpdate: NoAction)
}
model purchase_order_items {
id BigInt @id @default(autoincrement())
po_id BigInt
item_id BigInt
line_no Int
ordered_qty Decimal @db.Decimal(15, 4)
uom_id BigInt
rate Decimal @db.Decimal(15, 4)
discount_pct Decimal @default(0) @db.Decimal(5, 2)
discount_amount Decimal @default(0) @db.Decimal(15, 4)
gst_rate_id BigInt?
taxable_amount Decimal @default(0) @db.Decimal(15, 4)
tax_amount Decimal @default(0) @db.Decimal(15, 4)
line_total Decimal @default(0) @db.Decimal(15, 4)
received_qty Decimal @default(0) @db.Decimal(15, 4)
hsn_code_id BigInt?
remarks String?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
grn_items grn_items[]
gst_rates gst_rates? @relation(fields: [gst_rate_id], references: [id], onUpdate: NoAction)
hsn_codes hsn_codes? @relation(fields: [hsn_code_id], references: [id], onUpdate: NoAction)
items items @relation(fields: [item_id], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders @relation(fields: [po_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
uom uom @relation(fields: [uom_id], references: [id], onUpdate: NoAction)
@@unique([po_id, line_no])
@@index([item_id], map: "idx_po_items_item_id")
@@index([po_id], map: "idx_po_items_po_id")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model purchase_orders {
id BigInt @id @default(autoincrement())
po_number String @unique @db.VarChar(30)
po_date DateTime @db.Date
po_type String @db.VarChar(30)
vendor_id BigInt
plant_id BigInt
warehouse_id BigInt?
brand_id BigInt?
payment_term_id BigInt?
delivery_term_id BigInt?
expected_delivery_date DateTime? @db.Date
sub_total Decimal @default(0) @db.Decimal(15, 4)
discount_amount Decimal @default(0) @db.Decimal(15, 4)
tax_total Decimal @default(0) @db.Decimal(15, 4)
freight_charges Decimal @default(0) @db.Decimal(15, 4)
other_charges Decimal @default(0) @db.Decimal(15, 4)
grand_total Decimal @default(0) @db.Decimal(15, 4)
status String @default("DRAFT") @db.VarChar(30)
revision_no Int @default(0)
parent_po_id BigInt?
terms_and_conditions String?
remarks String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
assets assets[]
grn grn[]
po_approvals po_approvals[]
po_attachments po_attachments[]
purchase_order_items purchase_order_items[]
brands brands? @relation(fields: [brand_id], references: [id], onUpdate: NoAction)
users_purchase_orders_created_byTousers users? @relation("purchase_orders_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
delivery_terms delivery_terms? @relation(fields: [delivery_term_id], references: [id], onUpdate: NoAction)
purchase_orders purchase_orders? @relation("purchase_ordersTopurchase_orders", fields: [parent_po_id], references: [id], onUpdate: NoAction)
other_purchase_orders purchase_orders[] @relation("purchase_ordersTopurchase_orders")
payment_terms payment_terms? @relation(fields: [payment_term_id], references: [id], onUpdate: NoAction)
plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction)
users_purchase_orders_updated_byTousers users? @relation("purchase_orders_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction)
warehouses warehouses? @relation(fields: [warehouse_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([created_at], map: "idx_po_created_at")
@@index([po_date], map: "idx_po_po_date")
@@index([vendor_id], map: "idx_po_vendor_id")
}
model refresh_tokens {
id BigInt @id @default(autoincrement())
user_id BigInt
token_hash String @unique @db.VarChar(64)
expires_at DateTime @db.Timestamptz(6)
revoked_at DateTime? @db.Timestamptz(6)
created_at DateTime @default(now()) @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([user_id], map: "idx_refresh_tokens_user_id")
}
model role_permissions {
id BigInt @id @default(autoincrement())
role_id BigInt
permission_id BigInt
created_at DateTime @default(now()) @db.Timestamptz(6)
permissions permissions @relation(fields: [permission_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([role_id, permission_id])
}
model roles {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100)
description String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
po_approvals po_approvals[]
role_permissions role_permissions[]
users_roles_created_byTousers users? @relation("roles_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_roles_created_by")
users_roles_updated_byTousers users? @relation("roles_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_roles_updated_by")
users_users_role_idToroles users[] @relation("users_role_idToroles")
}
model uom {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
grn_items grn_items[]
items items[]
purchase_order_items purchase_order_items[]
users_uom_created_byTousers users? @relation("uom_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_uom_updated_byTousers users? @relation("uom_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model users {
id BigInt @id @default(autoincrement())
employee_code String @unique @db.VarChar(30)
full_name String @db.VarChar(200)
email String @unique @db.VarChar(200)
mobile String? @db.VarChar(20)
password_hash String @db.VarChar(255)
role_id BigInt?
department_id BigInt?
designation_id BigInt?
plant_id BigInt?
reporting_to BigInt?
status String @default("active") @db.VarChar(20)
is_active Boolean @default(true)
failed_login_attempts Int @default(0)
locked_until DateTime? @db.Timestamptz(6)
last_login_at DateTime? @db.Timestamptz(6)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
asset_attachments asset_attachments[]
asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers")
asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
asset_transfers_asset_transfers_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers")
asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers")
assets_assets_assigned_to_user_idTousers assets[] @relation("assets_assigned_to_user_idTousers")
assets_assets_created_byTousers assets[] @relation("assets_created_byTousers")
assets_assets_updated_byTousers assets[] @relation("assets_updated_byTousers")
audit_logs audit_logs[]
brands_brands_created_byTousers brands[] @relation("brands_created_byTousers")
brands_brands_updated_byTousers brands[] @relation("brands_updated_byTousers")
delivery_terms_delivery_terms_created_byTousers delivery_terms[] @relation("delivery_terms_created_byTousers")
delivery_terms_delivery_terms_updated_byTousers delivery_terms[] @relation("delivery_terms_updated_byTousers")
document_series_document_series_created_byTousers document_series[] @relation("document_series_created_byTousers")
document_series_document_series_updated_byTousers document_series[] @relation("document_series_updated_byTousers")
grn_grn_cancelled_byTousers grn[] @relation("grn_cancelled_byTousers")
grn_grn_created_byTousers grn[] @relation("grn_created_byTousers")
grn_grn_quality_checked_byTousers grn[] @relation("grn_quality_checked_byTousers")
grn_grn_received_byTousers grn[] @relation("grn_received_byTousers")
grn_grn_updated_byTousers grn[] @relation("grn_updated_byTousers")
grn_attachments grn_attachments[]
item_categories_item_categories_created_byTousers item_categories[] @relation("item_categories_created_byTousers")
item_categories_item_categories_updated_byTousers item_categories[] @relation("item_categories_updated_byTousers")
item_subcategories_item_subcategories_created_byTousers item_subcategories[] @relation("item_subcategories_created_byTousers")
item_subcategories_item_subcategories_updated_byTousers item_subcategories[] @relation("item_subcategories_updated_byTousers")
items_items_created_byTousers items[] @relation("items_created_byTousers")
items_items_updated_byTousers items[] @relation("items_updated_byTousers")
password_reset_tokens password_reset_tokens[]
payment_terms_payment_terms_created_byTousers payment_terms[] @relation("payment_terms_created_byTousers")
payment_terms_payment_terms_updated_byTousers payment_terms[] @relation("payment_terms_updated_byTousers")
plants_plants_created_byTousers plants[] @relation("plants_created_byTousers")
plants_plants_updated_byTousers plants[] @relation("plants_updated_byTousers")
po_approvals po_approvals[]
po_attachments po_attachments[]
purchase_orders_purchase_orders_created_byTousers purchase_orders[] @relation("purchase_orders_created_byTousers")
purchase_orders_purchase_orders_updated_byTousers purchase_orders[] @relation("purchase_orders_updated_byTousers")
refresh_tokens refresh_tokens[]
roles_roles_created_byTousers roles[] @relation("roles_created_byTousers")
roles_roles_updated_byTousers roles[] @relation("roles_updated_byTousers")
uom_uom_created_byTousers uom[] @relation("uom_created_byTousers")
uom_uom_updated_byTousers uom[] @relation("uom_updated_byTousers")
users_users_created_byTousers users? @relation("users_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
other_users_users_created_byTousers users[] @relation("users_created_byTousers")
departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction)
designations designations? @relation(fields: [designation_id], references: [id], onUpdate: NoAction)
plants_users_plant_idToplants plants? @relation("users_plant_idToplants", fields: [plant_id], references: [id], onUpdate: NoAction)
users_users_reporting_toTousers users? @relation("users_reporting_toTousers", fields: [reporting_to], references: [id], onUpdate: NoAction)
other_users_users_reporting_toTousers users[] @relation("users_reporting_toTousers")
roles_users_role_idToroles roles? @relation("users_role_idToroles", fields: [role_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
users_users_updated_byTousers users? @relation("users_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
other_users_users_updated_byTousers users[] @relation("users_updated_byTousers")
vendor_addresses_vendor_addresses_created_byTousers vendor_addresses[] @relation("vendor_addresses_created_byTousers")
vendor_addresses_vendor_addresses_updated_byTousers vendor_addresses[] @relation("vendor_addresses_updated_byTousers")
vendor_bank_details_vendor_bank_details_created_byTousers vendor_bank_details[] @relation("vendor_bank_details_created_byTousers")
vendor_bank_details_vendor_bank_details_updated_byTousers vendor_bank_details[] @relation("vendor_bank_details_updated_byTousers")
vendor_contacts_vendor_contacts_created_byTousers vendor_contacts[] @relation("vendor_contacts_created_byTousers")
vendor_contacts_vendor_contacts_updated_byTousers vendor_contacts[] @relation("vendor_contacts_updated_byTousers")
vendor_item_mapping_vendor_item_mapping_created_byTousers vendor_item_mapping[] @relation("vendor_item_mapping_created_byTousers")
vendor_item_mapping_vendor_item_mapping_updated_byTousers vendor_item_mapping[] @relation("vendor_item_mapping_updated_byTousers")
vendors_vendors_created_byTousers vendors[] @relation("vendors_created_byTousers")
vendors_vendors_updated_byTousers vendors[] @relation("vendors_updated_byTousers")
warehouses_warehouses_created_byTousers warehouses[] @relation("warehouses_created_byTousers")
warehouses_warehouses_updated_byTousers warehouses[] @relation("warehouses_updated_byTousers")
@@index([role_id], map: "idx_users_role_id")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model vendor_addresses {
id BigInt @id @default(autoincrement())
vendor_id BigInt
address_type String @db.VarChar(20)
address_line1 String? @db.VarChar(200)
address_line2 String? @db.VarChar(200)
city String? @db.VarChar(100)
state String? @db.VarChar(100)
pincode String? @db.VarChar(10)
country String @default("India") @db.VarChar(100)
gstin String? @db.VarChar(15)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users_vendor_addresses_created_byTousers users? @relation("vendor_addresses_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_vendor_addresses_updated_byTousers users? @relation("vendor_addresses_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model vendor_bank_details {
id BigInt @id @default(autoincrement())
vendor_id BigInt
bank_name String @db.VarChar(150)
branch String? @db.VarChar(150)
account_number String @db.VarChar(255)
account_number_index String? @db.VarChar(64)
ifsc String @db.VarChar(15)
account_holder_name String @db.VarChar(200)
account_type String @default("CURRENT") @db.VarChar(20)
is_primary Boolean @default(false)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users_vendor_bank_details_created_byTousers users? @relation("vendor_bank_details_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_vendor_bank_details_updated_byTousers users? @relation("vendor_bank_details_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
}
model vendor_contacts {
id BigInt @id @default(autoincrement())
vendor_id BigInt
contact_name String @db.VarChar(150)
designation String? @db.VarChar(100)
phone String? @db.VarChar(15)
email String? @db.VarChar(150)
is_primary Boolean @default(false)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users_vendor_contacts_created_byTousers users? @relation("vendor_contacts_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_vendor_contacts_updated_byTousers users? @relation("vendor_contacts_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
}
model vendor_item_mapping {
id BigInt @id @default(autoincrement())
vendor_id BigInt
item_id BigInt
last_purchase_rate Decimal? @db.Decimal(15, 4)
is_preferred Boolean @default(false)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
users_vendor_item_mapping_created_byTousers users? @relation("vendor_item_mapping_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
items items @relation(fields: [item_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users_vendor_item_mapping_updated_byTousers users? @relation("vendor_item_mapping_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
vendors vendors @relation(fields: [vendor_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([vendor_id, item_id])
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model vendors {
id BigInt @id @default(autoincrement())
vendor_code String @unique @db.VarChar(30)
vendor_name String @db.VarChar(200)
vendor_type String @db.VarChar(30)
gstin String? @db.VarChar(15)
pan String? @db.VarChar(10)
payment_term_id BigInt?
credit_period_days Int @default(0)
status String @default("active") @db.VarChar(20)
remarks String?
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
assets_assets_amc_vendor_idTovendors assets[] @relation("assets_amc_vendor_idTovendors")
assets_assets_vendor_idTovendors assets[] @relation("assets_vendor_idTovendors")
grn grn[]
purchase_orders purchase_orders[]
vendor_addresses vendor_addresses[]
vendor_bank_details vendor_bank_details[]
vendor_contacts vendor_contacts[]
vendor_item_mapping vendor_item_mapping[]
users_vendors_created_byTousers users? @relation("vendors_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
payment_terms payment_terms? @relation(fields: [payment_term_id], references: [id], onUpdate: NoAction)
users_vendors_updated_byTousers users? @relation("vendors_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
@@index([vendor_name(ops: raw("gin_trgm_ops"))], map: "idx_vendors_name_trgm", type: Gin)
}
model warehouses {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(150)
plant_id BigInt
location String? @db.VarChar(200)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
asset_transfers_asset_transfers_from_warehouse_idTowarehouses asset_transfers[] @relation("asset_transfers_from_warehouse_idTowarehouses")
asset_transfers_asset_transfers_to_warehouse_idTowarehouses asset_transfers[] @relation("asset_transfers_to_warehouse_idTowarehouses")
assets assets[]
grn grn[]
purchase_orders purchase_orders[]
users_warehouses_created_byTousers users? @relation("warehouses_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction)
users_warehouses_updated_byTousers users? @relation("warehouses_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
}

49
prisma/seed.js Normal file
View File

@ -0,0 +1,49 @@
/* eslint-disable no-console */
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const prisma = new PrismaClient();
async function main() {
const superAdminRole = await prisma.roles.findFirst({
where: { name: 'Super Admin', deleted_at: null },
});
if (!superAdminRole) {
throw new Error('Super Admin role not found. Run erp_phase1_ddl.sql first.');
}
const passwordHash = await bcrypt.hash('Admin@123', 12);
await prisma.users.upsert({
where: { email: 'admin@bharaterp.com' },
update: {
role_id: superAdminRole.id,
status: 'active',
is_active: true,
deleted_at: null,
},
create: {
employee_code: 'EMP001',
full_name: 'Super Admin',
email: 'admin@bharaterp.com',
password_hash: passwordHash,
role_id: superAdminRole.id,
status: 'active',
is_active: true,
},
});
console.log('Bootstrap Super Admin ready: admin@bharaterp.com / Admin@123');
}
main()
.then(async () => {
await prisma.$disconnect();
process.exit(0);
})
.catch(async (err) => {
console.error('Seed failed', err);
await prisma.$disconnect();
process.exit(1);
});

39
scripts/setup-db.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
DB_HOST="${DB_HOST:-demo.venbait.in}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-bharaterp}"
DB_USER="${DB_USER:-bharaterpdevdbuser}"
DB_PASSWORD="${DB_PASSWORD:-devdbuser@bharaterp}"
DDL_PATH="${1:-/home/smart/Downloads/erp_phase1_ddl.sql}"
PRISMA_VERSION="${PRISMA_VERSION:-5.22.0}"
if [ ! -f "$DDL_PATH" ]; then
echo "DDL file not found: $DDL_PATH"
exit 1
fi
ENCODED_PASSWORD="${DB_PASSWORD//@/%40}"
POSTGRES_URL="postgresql://${DB_USER}:${ENCODED_PASSWORD}@${DB_HOST}:${DB_PORT}/postgres?schema=public"
TARGET_URL="postgresql://${DB_USER}:${ENCODED_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public"
echo "Checking Prisma CLI availability (npx prisma@${PRISMA_VERSION})"
npx "prisma@${PRISMA_VERSION}" --version >/dev/null
echo "Trying to create database: $DB_NAME"
set +e
DATABASE_URL="$POSTGRES_URL" npx "prisma@${PRISMA_VERSION}" db execute --stdin --schema prisma/schema.prisma <<SQL
CREATE DATABASE ${DB_NAME};
SQL
CREATE_EXIT=$?
set -e
if [ "$CREATE_EXIT" -ne 0 ]; then
echo "Create DB skipped (likely already exists or insufficient CREATE DATABASE privilege). Continuing..."
fi
echo "Applying DDL file: $DDL_PATH"
DATABASE_URL="$TARGET_URL" npx "prisma@${PRISMA_VERSION}" db execute --file "$DDL_PATH" --schema prisma/schema.prisma
echo "Database setup complete for $DB_NAME on $DB_HOST"

55
src/app.js Normal file
View File

@ -0,0 +1,55 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const hpp = require('hpp');
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()) || ['*'] }));
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.get('/api-docs.json', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json(swaggerSpec);
});
app.use(
'/api-docs',
helmet({ contentSecurityPolicy: false }),
swaggerUi.serve,
swaggerUi.setup(null, {
explorer: true,
swaggerUrl: '/api-docs.json',
swaggerOptions: {
persistAuthorization: true,
},
})
);
app.use((req, _res, next) => next(new ApiError(404, `Route not found: ${req.originalUrl}`)));
app.use(errorMiddleware);
module.exports = app;

45
src/config/env.js Normal file
View File

@ -0,0 +1,45 @@
const path = require('path');
const dotenv = require('dotenv');
const Joi = require('joi');
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const envSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(3000),
DATABASE_URL: Joi.string().required(),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().required(),
DB_USER: Joi.string().required(),
DB_PASSWORD: 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;

53
src/config/logger.js Normal file
View File

@ -0,0 +1,53 @@
const winston = require('winston');
require('winston-daily-rotate-file');
const path = require('path');
const env = require('./env');
const { combine, timestamp, printf, errors, json } = 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(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
errors({ stack: true }),
printf(({ level, message, timestamp: ts, stack, ...meta }) => {
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
return `${ts} [${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;

12
src/config/morgan.js Normal file
View File

@ -0,0 +1,12 @@
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()) },
});

21
src/config/prisma.js Normal file
View File

@ -0,0 +1,21 @@
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;

45
src/config/swagger.js Normal file
View File

@ -0,0 +1,45 @@
const fs = require('fs');
const path = require('path');
const yaml = require('yaml');
const 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' } },
schemas: {},
},
security: [{ bearerAuth: [] }],
tags: [],
paths: {},
};
const docsDir = path.join(__dirname, '../docs');
if (fs.existsSync(docsDir)) {
for (const file of fs.readdirSync(docsDir)) {
if (!/\.ya?ml$/i.test(file)) continue;
const doc = yaml.parse(fs.readFileSync(path.join(docsDir, file), 'utf8'));
if (!doc) continue;
if (doc.tags?.length) {
definition.tags.push(...doc.tags);
}
if (doc.components?.schemas) {
Object.assign(definition.components.schemas, doc.components.schemas);
}
if (doc.paths) {
Object.assign(definition.paths, doc.paths);
}
}
}
module.exports = definition;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
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.users.findFirst({
where: { id: BigInt(payload.sub), deleted_at: null },
include: {
roles_users_role_idToroles: {
include: {
role_permissions: { include: { permissions: { include: { modules: true } } } },
},
},
},
});
if (!user || !user.is_active || user.status !== 'active') {
throw new ApiError(401, 'Invalid or inactive user');
}
user.role = user.roles_users_role_idToroles;
req.user = user;
return 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'));
return next(err instanceof ApiError ? err : new ApiError(401, 'Unauthorized'));
}
};

View File

@ -0,0 +1,31 @@
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,
});
return res.status(statusCode).json({
success: false,
message,
errors,
...(env.NODE_ENV === 'development' && { stack: err.stack }),
});
};

View File

@ -0,0 +1,21 @@
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 };

View File

@ -0,0 +1,13 @@
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.permissions.modules.code === moduleCode && rp.permissions.action === action
);
if (!allowed) return next(new ApiError(403, `Forbidden: requires ${moduleCode}:${action}`));
return next();
};
module.exports = authorize;

View File

@ -0,0 +1,7 @@
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();
};

View File

@ -0,0 +1,28 @@
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);
}
return cb(null, true);
};
module.exports = multer({
storage,
fileFilter,
limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
});

View File

@ -0,0 +1,20 @@
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;
return next();
};
module.exports = validate;

View File

@ -0,0 +1,20 @@
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 };

View File

@ -0,0 +1,13 @@
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;

View File

@ -0,0 +1,94 @@
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.refresh_tokens.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.users.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.users.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.users.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.refresh_tokens.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.refresh_tokens.update({
where: { id: record.id },
data: { revoked_at: new Date() },
});
const user = await prisma.users.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.refresh_tokens.updateMany({
where: { token_hash: tokenHash, revoked_at: null },
data: { revoked_at: new Date() },
});
};
module.exports = { login, refresh, logout };

View File

@ -0,0 +1,12 @@
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 };

View File

@ -0,0 +1,148 @@
const prisma = require('../../../config/prisma');
const ApiError = require('../../../utils/ApiError');
const auditLog = require('../../../utils/auditLog');
const { getPagination } = require('../../../utils/pagination');
const normalizePayload = (payload, fields) => {
const out = { ...payload };
for (const f of fields) {
if (Object.prototype.hasOwnProperty.call(out, f.name)) {
if (f.type === 'string' && out[f.name] !== null && out[f.name] !== undefined) {
out[f.name] = String(out[f.name]).trim();
if (f.uppercase) out[f.name] = out[f.name].toUpperCase();
}
if (f.type === 'int' && out[f.name] !== null && out[f.name] !== undefined) {
out[f.name] = Number(out[f.name]);
}
if (f.type === 'decimal' && out[f.name] !== null && out[f.name] !== undefined) {
out[f.name] = Number(out[f.name]);
}
}
}
return out;
};
const buildMasterService = ({
modelName,
tableName,
fields,
uniqueField = null,
softDelete = true,
}) => {
const model = prisma[modelName];
const createOne = async (payload, userId, requestId) => {
const data = normalizePayload(payload, fields);
if (uniqueField && data[uniqueField] !== undefined) {
const existing = await model.findFirst({
where: {
[uniqueField]: data[uniqueField],
...(softDelete ? { deleted_at: null } : {}),
},
});
if (existing) throw new ApiError(409, `${tableName} ${uniqueField} already exists`);
}
if (fields.some((f) => f.name === 'created_by'))
data.created_by = userId ? BigInt(userId) : null;
if (fields.some((f) => f.name === 'updated_by'))
data.updated_by = userId ? BigInt(userId) : null;
const created = await model.create({ data });
await auditLog({
tableName,
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: created,
userId,
requestId,
});
return created;
};
const list = async (query) => {
const { page, limit, skip } = getPagination(query);
const searchFields = fields.filter((f) => f.searchable).map((f) => f.name);
const where = {
...(softDelete ? { deleted_at: null } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.search && searchFields.length
? {
OR: searchFields.map((name) => ({
[name]: { contains: query.search, mode: 'insensitive' },
})),
}
: {}),
};
const [data, total] = await Promise.all([
model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit }),
model.count({ where }),
]);
return { data, meta: { page, limit, total } };
};
const getOne = async (id) => {
const one = await model.findFirst({
where: { id: BigInt(id), ...(softDelete ? { deleted_at: null } : {}) },
});
if (!one) throw new ApiError(404, `${tableName} not found`);
return one;
};
const updateOne = async (id, payload, userId, requestId) => {
const existing = await getOne(id);
const data = normalizePayload(payload, fields);
if (fields.some((f) => f.name === 'updated_by'))
data.updated_by = userId ? BigInt(userId) : null;
const updated = await model.update({ where: { id: BigInt(id) }, data });
await auditLog({
tableName,
recordId: id,
action: 'UPDATE',
oldValue: existing,
newValue: updated,
userId,
requestId,
});
return updated;
};
const removeOne = async (id, userId, requestId) => {
const existing = await getOne(id);
if (softDelete) {
await model.update({
where: { id: BigInt(id) },
data: {
deleted_at: new Date(),
...(fields.some((f) => f.name === 'updated_by')
? { updated_by: userId ? BigInt(userId) : null }
: {}),
},
});
} else {
await model.delete({ where: { id: BigInt(id) } });
}
await auditLog({
tableName,
recordId: id,
action: 'DELETE',
oldValue: existing,
newValue: softDelete ? { deleted_at: new Date() } : null,
userId,
requestId,
});
};
return { createOne, list, getOne, updateOne, removeOne };
};
module.exports = { buildMasterService };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./asset-categories.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createAssetCategories(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'asset_categories created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listAssetCategories(req.query);
res.json(new ApiResponse(200, result.data, 'asset_categories list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getAssetCategoriesById(req.params.id);
res.json(new ApiResponse(200, data, 'asset_categories fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateAssetCategories(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'asset_categories updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteAssetCategories(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'asset_categories deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./asset-categories.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./asset-categories.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,68 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'asset_categories',
tableName: 'asset_categories',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'code_prefix',
type: 'string',
uppercase: true,
searchable: false,
},
{
name: 'default_useful_life_years',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'default_depreciation_method',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createAssetCategories: service.createOne,
listAssetCategories: service.list,
getAssetCategoriesById: service.getOne,
updateAssetCategories: service.updateOne,
deleteAssetCategories: service.removeOne,
};

View File

@ -0,0 +1,21 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code_prefix: Joi.string().allow('').optional(),
default_useful_life_years: Joi.number().integer().optional(),
default_depreciation_method: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./brands.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createBrands(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'brands created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listBrands(req.query);
res.json(new ApiResponse(200, result.data, 'brands list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getBrandsById(req.params.id);
res.json(new ApiResponse(200, data, 'brands fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateBrands(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'brands updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteBrands(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'brands deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./brands.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./brands.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,74 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'brands',
tableName: 'brands',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'brand_type',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'contact_person',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'phone',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'email',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createBrands: service.createOne,
listBrands: service.list,
getBrandsById: service.getOne,
updateBrands: service.updateOne,
deleteBrands: service.removeOne,
};

View File

@ -0,0 +1,22 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
brand_type: Joi.string().allow('').optional(),
contact_person: Joi.string().allow('').optional(),
phone: Joi.string().allow('').optional(),
email: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./delivery-terms.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createDeliveryTerms(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'delivery_terms created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listDeliveryTerms(req.query);
res.json(new ApiResponse(200, result.data, 'delivery_terms list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getDeliveryTermsById(req.params.id);
res.json(new ApiResponse(200, data, 'delivery_terms fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateDeliveryTerms(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'delivery_terms updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteDeliveryTerms(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'delivery_terms deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./delivery-terms.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./delivery-terms.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,56 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'delivery_terms',
tableName: 'delivery_terms',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'description',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createDeliveryTerms: service.createOne,
listDeliveryTerms: service.list,
getDeliveryTermsById: service.getOne,
updateDeliveryTerms: service.updateOne,
deleteDeliveryTerms: service.removeOne,
};

View File

@ -0,0 +1,19 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./departments.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createDepartments(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'departments created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listDepartments(req.query);
res.json(new ApiResponse(200, result.data, 'departments list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getDepartmentsById(req.params.id);
res.json(new ApiResponse(200, data, 'departments fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateDepartments(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'departments updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteDepartments(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'departments deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./departments.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./departments.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,44 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'departments',
tableName: 'departments',
uniqueField: 'name',
softDelete: true,
fields: [
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createDepartments: service.createOne,
listDepartments: service.list,
getDepartmentsById: service.getOne,
updateDepartments: service.updateOne,
deleteDepartments: service.removeOne,
};

View File

@ -0,0 +1,17 @@
const Joi = require('joi');
const createSchema = Joi.object({
name: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./designations.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createDesignations(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'designations created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listDesignations(req.query);
res.json(new ApiResponse(200, result.data, 'designations list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getDesignationsById(req.params.id);
res.json(new ApiResponse(200, data, 'designations fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateDesignations(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'designations updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteDesignations(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'designations deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./designations.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./designations.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,44 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'designations',
tableName: 'designations',
uniqueField: 'name',
softDelete: true,
fields: [
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createDesignations: service.createOne,
listDesignations: service.list,
getDesignationsById: service.getOne,
updateDesignations: service.updateOne,
deleteDesignations: service.removeOne,
};

View File

@ -0,0 +1,17 @@
const Joi = require('joi');
const createSchema = Joi.object({
name: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./document-series.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createDocumentSeries(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'document_series created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listDocumentSeries(req.query);
res.json(new ApiResponse(200, result.data, 'document_series list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getDocumentSeriesById(req.params.id);
res.json(new ApiResponse(200, data, 'document_series fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateDocumentSeries(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'document_series updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteDocumentSeries(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'document_series deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./document-series.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./document-series.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,68 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'document_series',
tableName: 'document_series',
uniqueField: 'code',
softDelete: false,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'prefix',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'current_number',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'padding',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'description',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createDocumentSeries: service.createOne,
listDocumentSeries: service.list,
getDocumentSeriesById: service.getOne,
updateDocumentSeries: service.updateOne,
deleteDocumentSeries: service.removeOne,
};

View File

@ -0,0 +1,21 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
prefix: Joi.string().allow('').optional(),
current_number: Joi.number().integer().optional(),
padding: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./gst-rates.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createGstRates(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'gst_rates created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listGstRates(req.query);
res.json(new ApiResponse(200, result.data, 'gst_rates list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getGstRatesById(req.params.id);
res.json(new ApiResponse(200, data, 'gst_rates fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateGstRates(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'gst_rates updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteGstRates(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'gst_rates deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./gst-rates.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./gst-rates.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,38 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'gst_rates',
tableName: 'gst_rates',
uniqueField: 'rate_pct',
softDelete: false,
fields: [
{
name: 'rate_pct',
type: 'decimal',
uppercase: false,
searchable: false,
},
{
name: 'description',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createGstRates: service.createOne,
listGstRates: service.list,
getGstRatesById: service.getOne,
updateGstRates: service.updateOne,
deleteGstRates: service.removeOne,
};

View File

@ -0,0 +1,18 @@
const Joi = require('joi');
const createSchema = Joi.object({
rate_pct: Joi.number().precision(2).optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,20 @@
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('/brands', require('./brands/brands.routes'));
router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
router.use('/asset-categories', require('./asset-categories/asset-categories.routes'));
router.use('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes'));
router.use('/document-series', require('./document-series/document-series.routes'));
router.use('/item-subcategories', require('./item-subcategories/item-subcategories.routes'));
router.use('/items', require('./items/items.routes'));
router.use('/warehouses', require('./warehouses/warehouses.routes'));
router.use('/plants', require('./plants/plants.routes'));
module.exports = router;

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./item-categories.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createItemCategories(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'item_categories created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listItemCategories(req.query);
res.json(new ApiResponse(200, result.data, 'item_categories list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getItemCategoriesById(req.params.id);
res.json(new ApiResponse(200, data, 'item_categories fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateItemCategories(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'item_categories updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteItemCategories(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'item_categories deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./item-categories.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./item-categories.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,50 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'item_categories',
tableName: 'item_categories',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createItemCategories: service.createOne,
listItemCategories: service.list,
getItemCategoriesById: service.getOne,
updateItemCategories: service.updateOne,
deleteItemCategories: service.removeOne,
};

View File

@ -0,0 +1,18 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./item-subcategories.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createItemSubcategories(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'item_subcategories created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listItemSubcategories(req.query);
res.json(new ApiResponse(200, result.data, 'item_subcategories list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getItemSubcategoriesById(req.params.id);
res.json(new ApiResponse(200, data, 'item_subcategories fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateItemSubcategories(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'item_subcategories updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteItemSubcategories(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'item_subcategories deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./item-subcategories.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./item-subcategories.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,56 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'item_subcategories',
tableName: 'item_subcategories',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'item_category_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createItemSubcategories: service.createOne,
listItemSubcategories: service.list,
getItemSubcategoriesById: service.getOne,
updateItemSubcategories: service.updateOne,
deleteItemSubcategories: service.removeOne,
};

View File

@ -0,0 +1,19 @@
const Joi = require('joi');
const createSchema = Joi.object({
item_category_id: Joi.number().integer().optional(),
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./items.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createItems(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'items created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listItems(req.query);
res.json(new ApiResponse(200, result.data, 'items list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getItemsById(req.params.id);
res.json(new ApiResponse(200, data, 'items fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateItems(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'items updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteItems(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'items deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./items.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./items.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,116 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'items',
tableName: 'items',
uniqueField: 'item_code',
softDelete: true,
fields: [
{
name: 'item_code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'item_name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'item_category_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'item_subcategory_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'uom_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'hsn_code_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'gst_rate_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'brand_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'is_asset_item',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'description',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'specification',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'min_order_qty',
type: 'decimal',
uppercase: false,
searchable: false,
},
{
name: 'reorder_level',
type: 'decimal',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createItems: service.createOne,
listItems: service.list,
getItemsById: service.getOne,
updateItems: service.updateOne,
deleteItems: service.removeOne,
};

View File

@ -0,0 +1,29 @@
const Joi = require('joi');
const createSchema = Joi.object({
item_code: Joi.string().allow('').optional(),
item_name: Joi.string().allow('').optional(),
item_category_id: Joi.number().integer().optional(),
item_subcategory_id: Joi.number().integer().optional(),
uom_id: Joi.number().integer().optional(),
hsn_code_id: Joi.number().integer().optional(),
gst_rate_id: Joi.number().integer().optional(),
brand_id: Joi.number().integer().optional(),
is_asset_item: Joi.boolean().optional(),
description: Joi.string().allow('').optional(),
specification: Joi.string().allow('').optional(),
min_order_qty: Joi.number().precision(4).optional(),
reorder_level: Joi.number().precision(4).optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./payment-terms.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createPaymentTerms(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'payment_terms created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listPaymentTerms(req.query);
res.json(new ApiResponse(200, result.data, 'payment_terms list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getPaymentTermsById(req.params.id);
res.json(new ApiResponse(200, data, 'payment_terms fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updatePaymentTerms(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'payment_terms updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deletePaymentTerms(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'payment_terms deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./payment-terms.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./payment-terms.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,62 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'payment_terms',
tableName: 'payment_terms',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'credit_days',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'description',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createPaymentTerms: service.createOne,
listPaymentTerms: service.list,
getPaymentTermsById: service.getOne,
updatePaymentTerms: service.updateOne,
deletePaymentTerms: service.removeOne,
};

View File

@ -0,0 +1,20 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
credit_days: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./plants.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createPlants(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'plants created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listPlants(req.query);
res.json(new ApiResponse(200, result.data, 'plants list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getPlantsById(req.params.id);
res.json(new ApiResponse(200, data, 'plants fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updatePlants(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'plants updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deletePlants(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'plants deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./plants.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./plants.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,86 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'plants',
tableName: 'plants',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'gstin',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'address',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'city',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'state',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'pincode',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'phone',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createPlants: service.createOne,
listPlants: service.list,
getPlantsById: service.getOne,
updatePlants: service.updateOne,
deletePlants: service.removeOne,
};

View File

@ -0,0 +1,24 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
gstin: Joi.string().allow('').optional(),
address: Joi.string().allow('').optional(),
city: Joi.string().allow('').optional(),
state: Joi.string().allow('').optional(),
pincode: Joi.string().allow('').optional(),
phone: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./uom.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createUom(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'UOM created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listUom(req.query);
res.json(new ApiResponse(200, result.data, 'UOM list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getUomById(req.params.id);
res.json(new ApiResponse(200, data, 'UOM fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateUom(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'UOM updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteUom(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'UOM deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,23 @@
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('./uom.controller');
const { createUomSchema, updateUomSchema, listUomQuerySchema } = require('./uom.validation');
const router = express.Router();
router.use(authenticate);
router.get(
'/',
authorize('MASTERS', 'view'),
validate(listUomQuerySchema, 'query'),
controller.list
);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createUomSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateUomSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,104 @@
const prisma = require('../../../config/prisma');
const ApiError = require('../../../utils/ApiError');
const auditLog = require('../../../utils/auditLog');
const { getPagination } = require('../../../utils/pagination');
const createUom = async (payload, userId, requestId) => {
const exists = await prisma.uom.findFirst({
where: { code: payload.code, deleted_at: null },
});
if (exists) throw new ApiError(409, 'UOM code already exists');
const data = {
...payload,
code: payload.code.toUpperCase().trim(),
name: payload.name.trim(),
created_by: userId ? BigInt(userId) : null,
updated_by: userId ? BigInt(userId) : null,
};
const created = await prisma.uom.create({ data });
await auditLog({
tableName: 'uom',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: created,
userId,
requestId,
});
return created;
};
const listUom = async (query) => {
const { page, limit, skip } = getPagination(query);
const where = {
deleted_at: null,
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.search
? {
OR: [
{ code: { contains: query.search, mode: 'insensitive' } },
{ name: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
};
const [data, total] = await Promise.all([
prisma.uom.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit }),
prisma.uom.count({ where }),
]);
return { data, meta: { page, limit, total } };
};
const getUomById = async (id) => {
const uom = await prisma.uom.findFirst({ where: { id: BigInt(id), deleted_at: null } });
if (!uom) throw new ApiError(404, 'UOM not found');
return uom;
};
const updateUom = async (id, payload, userId, requestId) => {
const existing = await getUomById(id);
const updated = await prisma.uom.update({
where: { id: BigInt(id) },
data: {
...payload,
...(payload.code ? { code: payload.code.toUpperCase().trim() } : {}),
...(payload.name ? { name: payload.name.trim() } : {}),
updated_by: userId ? BigInt(userId) : null,
},
});
await auditLog({
tableName: 'uom',
recordId: id,
action: 'UPDATE',
oldValue: existing,
newValue: updated,
userId,
requestId,
});
return updated;
};
const deleteUom = async (id, userId, requestId) => {
const existing = await getUomById(id);
await prisma.uom.update({
where: { id: BigInt(id) },
data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null },
});
await auditLog({
tableName: 'uom',
recordId: id,
action: 'DELETE',
oldValue: existing,
newValue: { deleted_at: new Date() },
userId,
requestId,
});
};
module.exports = { createUom, listUom, getUomById, updateUom, deleteUom };

View File

@ -0,0 +1,22 @@
const Joi = require('joi');
const createUomSchema = Joi.object({
code: Joi.string().max(20).required(),
name: Joi.string().max(100).required(),
is_active: Joi.boolean().optional(),
});
const updateUomSchema = Joi.object({
code: Joi.string().max(20).optional(),
name: Joi.string().max(100).optional(),
is_active: Joi.boolean().optional(),
}).min(1);
const listUomQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createUomSchema, updateUomSchema, listUomQuerySchema };

View File

@ -0,0 +1,30 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./warehouses.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createWarehouses(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'warehouses created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listWarehouses(req.query);
res.json(new ApiResponse(200, result.data, 'warehouses list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getWarehousesById(req.params.id);
res.json(new ApiResponse(200, data, 'warehouses fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateWarehouses(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'warehouses updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteWarehouses(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'warehouses deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./warehouses.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./warehouses.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,62 @@
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'warehouses',
tableName: 'warehouses',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'plant_id',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'location',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
module.exports = {
createWarehouses: service.createOne,
listWarehouses: service.list,
getWarehousesById: service.getOne,
updateWarehouses: service.updateOne,
deleteWarehouses: service.removeOne,
};

View File

@ -0,0 +1,20 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
plant_id: Joi.number().integer().optional(),
location: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,76 @@
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./roles.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createRole(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'Role created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listRoles(req.query);
res.json(new ApiResponse(200, result.data, 'Roles list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getRoleById(req.params.id);
res.json(new ApiResponse(200, data, 'Role fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateRole(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'Role updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteRole(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'Role deleted successfully'));
});
const listPermissions = asyncHandler(async (_req, res) => {
const data = await service.listPermissionCatalog();
res.json(new ApiResponse(200, data, 'Permission catalog fetched'));
});
const assignPermissions = asyncHandler(async (req, res) => {
const data = await service.assignPermissions(
req.params.id,
req.body.permission_ids,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, data, 'Role permissions updated successfully'));
});
const listCards = asyncHandler(async (_req, res) => {
const data = await service.listRoleCards();
res.json(new ApiResponse(200, data, 'Role cards fetched'));
});
const getPermissionMatrix = asyncHandler(async (req, res) => {
const data = await service.getPermissionMatrix(req.params.id);
res.json(new ApiResponse(200, data, 'Permission matrix fetched'));
});
const savePermissionMatrix = asyncHandler(async (req, res) => {
const data = await service.savePermissionMatrix(
req.params.id,
req.body.matrix,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, data, 'Permission matrix saved successfully'));
});
module.exports = {
create,
list,
getOne,
update,
remove,
listPermissions,
assignPermissions,
listCards,
getPermissionMatrix,
savePermissionMatrix,
};

View File

@ -0,0 +1,43 @@
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('./roles.controller');
const {
createRoleSchema,
updateRoleSchema,
assignPermissionsSchema,
permissionMatrixSchema,
listRolesQuerySchema,
} = require('./roles.validation');
const router = express.Router();
router.use(authenticate);
router.get('/permissions', authorize('ROLES', 'view'), controller.listPermissions);
router.get('/cards', authorize('ROLES', 'view'), controller.listCards);
router.get(
'/',
authorize('ROLES', 'view'),
validate(listRolesQuerySchema, 'query'),
controller.list
);
router.get('/:id', authorize('ROLES', 'view'), controller.getOne);
router.post('/', authorize('ROLES', 'create'), validate(createRoleSchema), controller.create);
router.put('/:id', authorize('ROLES', 'edit'), validate(updateRoleSchema), controller.update);
router.put(
'/:id/permissions',
authorize('ROLES', 'edit'),
validate(assignPermissionsSchema),
controller.assignPermissions
);
router.get('/:id/permission-matrix', authorize('ROLES', 'view'), controller.getPermissionMatrix);
router.put(
'/:id/permission-matrix',
authorize('ROLES', 'edit'),
validate(permissionMatrixSchema),
controller.savePermissionMatrix
);
router.delete('/:id', authorize('ROLES', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,355 @@
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const PERMISSION_ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export'];
const permissionSelect = {
permissions: {
select: {
id: true,
action: true,
is_active: true,
modules: { select: { id: true, code: true, name: true } },
},
},
};
const rolePermissionsInclude = { role_permissions: { include: permissionSelect } };
const sanitizeRole = (role) => {
if (!role) return null;
const { role_permissions, ...rest } = role;
return {
...rest,
permissions: (role_permissions || []).map((rp) => ({
id: rp.permissions.id,
action: rp.permissions.action,
is_active: rp.permissions.is_active,
module: rp.permissions.modules,
})),
};
};
const createRole = async (payload, userId, requestId) => {
const existing = await prisma.roles.findFirst({
where: { name: payload.name, deleted_at: null },
});
if (existing) throw new ApiError(409, 'Role name already exists');
const created = await prisma.roles.create({
data: {
name: payload.name.trim(),
description: payload.description ?? null,
is_active: payload.is_active ?? true,
created_by: userId ? BigInt(userId) : null,
updated_by: userId ? BigInt(userId) : null,
},
include: rolePermissionsInclude,
});
await auditLog({
tableName: 'roles',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitizeRole(created),
userId,
requestId,
});
return sanitizeRole(created);
};
const listRoles = async (query) => {
const { page, limit, skip } = getPagination(query);
const where = {
deleted_at: null,
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.search
? {
OR: [
{ name: { contains: query.search, mode: 'insensitive' } },
{ description: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.roles.findMany({
where,
include: {
role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } },
},
orderBy: { created_at: 'desc' },
skip,
take: limit,
}),
prisma.roles.count({ where }),
]);
const data = rows.map((role) => ({
...sanitizeRole(role),
user_count: role._count.users_users_role_idToroles,
}));
return { data, meta: { page, limit, total } };
};
const getRoleById = async (id) => {
const role = await prisma.roles.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: {
role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } },
},
});
if (!role) throw new ApiError(404, 'Role not found');
return { ...sanitizeRole(role), user_count: role._count.users_users_role_idToroles };
};
const updateRole = async (id, payload, userId, requestId) => {
const existing = await prisma.roles.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: rolePermissionsInclude,
});
if (!existing) throw new ApiError(404, 'Role not found');
if (payload.name) {
const duplicate = await prisma.roles.findFirst({
where: { name: payload.name, deleted_at: null, id: { not: BigInt(id) } },
});
if (duplicate) throw new ApiError(409, 'Role name already exists');
}
const data = {
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
...(payload.description !== undefined ? { description: payload.description || null } : {}),
...(payload.is_active !== undefined ? { is_active: payload.is_active } : {}),
updated_by: userId ? BigInt(userId) : null,
};
const updated = await prisma.roles.update({
where: { id: BigInt(id) },
data,
include: rolePermissionsInclude,
});
await auditLog({
tableName: 'roles',
recordId: id,
action: 'UPDATE',
oldValue: sanitizeRole(existing),
newValue: sanitizeRole(updated),
userId,
requestId,
});
return sanitizeRole(updated);
};
const deleteRole = async (id, userId, requestId) => {
const existing = await prisma.roles.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: rolePermissionsInclude,
});
if (!existing) throw new ApiError(404, 'Role not found');
const assignedUsers = await prisma.users.count({
where: { role_id: BigInt(id), deleted_at: null },
});
if (assignedUsers > 0) {
throw new ApiError(400, 'Cannot delete role assigned to active users');
}
await prisma.roles.update({
where: { id: BigInt(id) },
data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null, is_active: false },
});
await auditLog({
tableName: 'roles',
recordId: id,
action: 'DELETE',
oldValue: sanitizeRole(existing),
newValue: { deleted_at: new Date() },
userId,
requestId,
});
};
const listPermissionCatalog = async () => {
const modules = await prisma.modules.findMany({
where: { is_active: true },
orderBy: { sort_order: 'asc' },
include: {
permissions: {
where: { is_active: true },
orderBy: { action: 'asc' },
select: { id: true, action: true, is_active: true },
},
},
});
return modules.map((m) => ({
id: m.id,
code: m.code,
name: m.name,
permissions: m.permissions,
}));
};
const assignPermissions = async (id, permissionIds, userId, requestId) => {
const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } });
if (!role) throw new ApiError(404, 'Role not found');
const uniqueIds = [...new Set(permissionIds.map((pid) => BigInt(pid)))];
if (uniqueIds.length > 0) {
const permissions = await prisma.permissions.findMany({
where: { id: { in: uniqueIds }, is_active: true },
include: { modules: { select: { code: true } } },
});
if (permissions.length !== uniqueIds.length) {
throw new ApiError(422, 'One or more permission_ids are invalid');
}
}
const before = await prisma.role_permissions.findMany({
where: { role_id: BigInt(id) },
include: permissionSelect,
});
await prisma.$transaction([
prisma.role_permissions.deleteMany({ where: { role_id: BigInt(id) } }),
prisma.role_permissions.createMany({
data: uniqueIds.map((permission_id) => ({ role_id: BigInt(id), permission_id })),
}),
prisma.roles.update({
where: { id: BigInt(id) },
data: { updated_by: userId ? BigInt(userId) : null },
}),
]);
const updated = await prisma.roles.findFirst({
where: { id: BigInt(id) },
include: rolePermissionsInclude,
});
await auditLog({
tableName: 'role_permissions',
recordId: id,
action: 'UPDATE',
oldValue: before.map((rp) => rp.permissions.id.toString()),
newValue: uniqueIds.map((pid) => pid.toString()),
userId,
requestId,
});
return sanitizeRole(updated);
};
const listRoleCards = async () => {
const rows = await prisma.roles.findMany({
where: { deleted_at: null },
include: {
role_permissions: { include: permissionSelect },
_count: { select: { users_users_role_idToroles: true } },
},
orderBy: { created_at: 'asc' },
});
return rows.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
is_active: role.is_active,
user_count: role._count.users_users_role_idToroles,
permission_count: role.role_permissions.length,
}));
};
const getPermissionMatrix = async (id) => {
const role = await prisma.roles.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: { role_permissions: { include: permissionSelect } },
});
if (!role) throw new ApiError(404, 'Role not found');
const granted = new Set(role.role_permissions.map((rp) => rp.permissions.id.toString()));
const modules = await prisma.modules.findMany({
where: { is_active: true },
orderBy: { sort_order: 'asc' },
include: {
permissions: { where: { is_active: true }, orderBy: { action: 'asc' } },
},
});
const matrix = modules.map((mod) => {
const permissions = {};
for (const action of PERMISSION_ACTIONS) {
const perm = mod.permissions.find((p) => p.action === action);
permissions[action] = {
permission_id: perm?.id ?? null,
granted: perm ? granted.has(perm.id.toString()) : false,
};
}
return {
module_id: mod.id,
code: mod.code,
name: mod.name,
permissions,
};
});
return {
role: { id: role.id, name: role.name },
actions: PERMISSION_ACTIONS,
modules: matrix,
};
};
const savePermissionMatrix = async (id, matrix, userId, requestId) => {
const role = await prisma.roles.findFirst({ where: { id: BigInt(id), deleted_at: null } });
if (!role) throw new ApiError(404, 'Role not found');
const moduleIds = matrix.map((row) => BigInt(row.module_id));
const catalog = await prisma.permissions.findMany({
where: { is_active: true, modules: { id: { in: moduleIds }, is_active: true } },
select: { id: true, module_id: true, action: true },
});
const permissionIds = [];
for (const row of matrix) {
for (const action of PERMISSION_ACTIONS) {
if (!row.actions?.[action]) continue;
const perm = catalog.find(
(p) => p.module_id.toString() === String(row.module_id) && p.action === action
);
if (!perm)
throw new ApiError(422, `Permission not found for module ${row.module_id}:${action}`);
permissionIds.push(perm.id);
}
}
return assignPermissions(id, permissionIds, userId, requestId);
};
module.exports = {
createRole,
listRoles,
getRoleById,
updateRole,
deleteRole,
listPermissionCatalog,
assignPermissions,
listRoleCards,
getPermissionMatrix,
savePermissionMatrix,
};

View File

@ -0,0 +1,51 @@
const Joi = require('joi');
const createRoleSchema = Joi.object({
name: Joi.string().max(100).required(),
description: Joi.string().allow(null, '').optional(),
is_active: Joi.boolean().default(true),
});
const updateRoleSchema = Joi.object({
name: Joi.string().max(100).optional(),
description: Joi.string().allow(null, '').optional(),
is_active: Joi.boolean().optional(),
}).min(1);
const assignPermissionsSchema = Joi.object({
permission_ids: Joi.array().items(Joi.number().integer().positive()).default([]),
});
const permissionMatrixSchema = Joi.object({
matrix: Joi.array()
.items(
Joi.object({
module_id: Joi.number().integer().positive().required(),
actions: Joi.object({
view: Joi.boolean().required(),
create: Joi.boolean().required(),
edit: Joi.boolean().required(),
delete: Joi.boolean().required(),
approve: Joi.boolean().required(),
export: Joi.boolean().required(),
}).required(),
})
)
.min(1)
.required(),
});
const listRolesQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
search: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
module.exports = {
createRoleSchema,
updateRoleSchema,
assignPermissionsSchema,
permissionMatrixSchema,
listRolesQuerySchema,
};

View File

@ -0,0 +1,47 @@
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./users.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createUser(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'User created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listUsers(req.query);
res.json(new ApiResponse(200, result.data, 'Users list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getUserById(req.params.id);
res.json(new ApiResponse(200, data, 'User fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateUser(req.params.id, req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'User updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteUser(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'User deleted successfully'));
});
const summary = asyncHandler(async (_req, res) => {
const data = await service.getScreenSummary();
res.json(new ApiResponse(200, data, 'Users & roles summary fetched'));
});
const filters = asyncHandler(async (_req, res) => {
const data = await service.getFilterOptions();
res.json(new ApiResponse(200, data, 'User filter options fetched'));
});
const exportCsv = asyncHandler(async (req, res) => {
const csv = await service.exportUsers(req.query);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="users-export.csv"');
res.send(csv);
});
module.exports = { create, list, getOne, update, remove, summary, filters, exportCsv };

View File

@ -0,0 +1,35 @@
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('./users.controller');
const {
createUserSchema,
updateUserSchema,
listUsersQuerySchema,
exportUsersQuerySchema,
} = require('./users.validation');
const router = express.Router();
router.use(authenticate);
router.get('/summary', authorize('USERS', 'view'), controller.summary);
router.get('/filters', authorize('USERS', 'view'), controller.filters);
router.get(
'/export',
authorize('USERS', 'export'),
validate(exportUsersQuerySchema, 'query'),
controller.exportCsv
);
router.get(
'/',
authorize('USERS', 'view'),
validate(listUsersQuerySchema, 'query'),
controller.list
);
router.get('/:id', authorize('USERS', 'view'), controller.getOne);
router.post('/', authorize('USERS', 'create'), validate(createUserSchema), controller.create);
router.put('/:id', authorize('USERS', 'edit'), validate(updateUserSchema), controller.update);
router.delete('/:id', authorize('USERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,363 @@
const bcrypt = require('bcrypt');
const prisma = require('../../config/prisma');
const env = require('../../config/env');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { encrypt, decrypt } = require('../../utils/encryption');
const userInclude = {
roles_users_role_idToroles: { select: { id: true, name: true } },
departments: { select: { id: true, name: true } },
designations: { select: { id: true, name: true } },
plants_users_plant_idToplants: { select: { id: true, code: true, name: true } },
users_users_reporting_toTousers: { select: { id: true, full_name: true, employee_code: true } },
};
const sanitizeUser = (user) => {
if (!user) return null;
const {
roles_users_role_idToroles,
plants_users_plant_idToplants,
users_users_reporting_toTousers,
departments,
designations,
...rest
} = user;
delete rest.password_hash;
return {
...rest,
mobile: rest.mobile ? decrypt(rest.mobile) : null,
role: roles_users_role_idToroles || null,
department: departments || null,
designation: designations || null,
plant: plants_users_plant_idToplants || null,
reporting_manager: users_users_reporting_toTousers || null,
};
};
const buildUsersWhere = (query) => ({
deleted_at: null,
...(query.status ? { status: query.status } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.role_id ? { role_id: BigInt(query.role_id) } : {}),
...(query.department_id ? { department_id: BigInt(query.department_id) } : {}),
...(query.search
? {
OR: [
{ full_name: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } },
{ employee_code: { contains: query.search, mode: 'insensitive' } },
],
}
: {}),
});
const toInitials = (name) => {
if (!name) return '';
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
};
const toUserListItem = (user) => {
const row = sanitizeUser(user);
return {
id: row.id,
full_name: row.full_name,
email: row.email,
initials: toInitials(row.full_name),
employee_code: row.employee_code,
role: row.role,
department: row.department,
plant: row.plant,
last_login_at: row.last_login_at,
status: row.status,
is_active: row.is_active,
};
};
const assertFk = async (payload) => {
if (payload.role_id) {
const role = await prisma.roles.findFirst({
where: { id: BigInt(payload.role_id), deleted_at: null, is_active: true },
});
if (!role) throw new ApiError(422, 'Invalid role_id');
}
if (payload.department_id) {
const row = await prisma.departments.findFirst({
where: { id: BigInt(payload.department_id), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid department_id');
}
if (payload.designation_id) {
const row = await prisma.designations.findFirst({
where: { id: BigInt(payload.designation_id), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid designation_id');
}
if (payload.plant_id) {
const row = await prisma.plants.findFirst({
where: { id: BigInt(payload.plant_id), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid plant_id');
}
if (payload.reporting_to) {
const row = await prisma.users.findFirst({
where: { id: BigInt(payload.reporting_to), deleted_at: null },
});
if (!row) throw new ApiError(422, 'Invalid reporting_to');
}
};
const buildUserData = async (payload, { hashPassword = false } = {}) => {
const data = { ...payload };
if (hashPassword && data.password) {
data.password_hash = await bcrypt.hash(data.password, env.BCRYPT_SALT_ROUNDS);
delete data.password;
} else {
delete data.password;
}
if (Object.prototype.hasOwnProperty.call(data, 'mobile')) {
data.mobile = data.mobile ? encrypt(data.mobile) : null;
}
for (const key of ['role_id', 'department_id', 'designation_id', 'plant_id', 'reporting_to']) {
if (data[key] !== undefined && data[key] !== null) data[key] = BigInt(data[key]);
}
return data;
};
const createUser = async (payload, userId, requestId) => {
await assertFk(payload);
const existing = await prisma.users.findFirst({
where: {
deleted_at: null,
OR: [{ email: payload.email }, { employee_code: payload.employee_code }],
},
});
if (existing) throw new ApiError(409, 'User with this email or employee code already exists');
const data = await buildUserData(payload, { hashPassword: true });
data.created_by = userId ? BigInt(userId) : null;
data.updated_by = userId ? BigInt(userId) : null;
const created = await prisma.users.create({ data, include: userInclude });
await auditLog({
tableName: 'users',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitizeUser(created),
userId,
requestId,
});
return sanitizeUser(created);
};
const listUsers = async (query) => {
const { page, limit, skip } = getPagination(query);
const where = buildUsersWhere(query);
const [rows, total] = await Promise.all([
prisma.users.findMany({
where,
include: userInclude,
orderBy: { created_at: 'desc' },
skip,
take: limit,
}),
prisma.users.count({ where }),
]);
return { data: rows.map(toUserListItem), meta: { page, limit, total } };
};
const getScreenSummary = async () => {
const baseWhere = { deleted_at: null };
const [total, active, inactive, locked, rolesTotal] = await Promise.all([
prisma.users.count({ where: baseWhere }),
prisma.users.count({ where: { ...baseWhere, status: 'active' } }),
prisma.users.count({ where: { ...baseWhere, status: 'inactive' } }),
prisma.users.count({ where: { ...baseWhere, status: 'locked' } }),
prisma.roles.count({ where: { deleted_at: null } }),
]);
return {
users: { total, active, inactive, locked },
roles: { total: rolesTotal },
tabs: { users: total, roles: rolesTotal },
};
};
const getFilterOptions = async () => {
const [roles, departments] = await Promise.all([
prisma.roles.findMany({
where: { deleted_at: null, is_active: true },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.departments.findMany({
where: { deleted_at: null, is_active: true },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
]);
return {
roles,
departments,
statuses: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'locked', label: 'Locked' },
],
};
};
const escapeCsv = (value) => {
const text = String(value ?? '');
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
};
const exportUsers = async (query) => {
const where = buildUsersWhere(query);
const rows = await prisma.users.findMany({
where,
include: userInclude,
orderBy: { full_name: 'asc' },
});
const header = [
'Full Name',
'Email',
'Employee Code',
'Role',
'Department',
'Plant',
'Last Login',
'Status',
];
const lines = rows.map((user) => {
const item = toUserListItem(user);
return [
item.full_name,
item.email,
item.employee_code,
item.role?.name || '',
item.department?.name || '',
item.plant?.name || '',
item.last_login_at ? new Date(item.last_login_at).toISOString() : '',
item.status,
]
.map(escapeCsv)
.join(',');
});
return [header.join(','), ...lines].join('\n');
};
const getUserById = async (id) => {
const user = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!user) throw new ApiError(404, 'User not found');
return sanitizeUser(user);
};
const updateUser = async (id, payload, userId, requestId) => {
const existing = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!existing) throw new ApiError(404, 'User not found');
await assertFk(payload);
if (payload.email || payload.employee_code) {
const duplicate = await prisma.users.findFirst({
where: {
deleted_at: null,
id: { not: BigInt(id) },
OR: [
...(payload.email ? [{ email: payload.email }] : []),
...(payload.employee_code ? [{ employee_code: payload.employee_code }] : []),
],
},
});
if (duplicate) throw new ApiError(409, 'User with this email or employee code already exists');
}
const data = await buildUserData(payload, { hashPassword: Boolean(payload.password) });
data.updated_by = userId ? BigInt(userId) : null;
const updated = await prisma.users.update({
where: { id: BigInt(id) },
data,
include: userInclude,
});
await auditLog({
tableName: 'users',
recordId: id,
action: 'UPDATE',
oldValue: sanitizeUser(existing),
newValue: sanitizeUser(updated),
userId,
requestId,
});
return sanitizeUser(updated);
};
const deleteUser = async (id, userId, requestId) => {
const existing = await prisma.users.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: userInclude,
});
if (!existing) throw new ApiError(404, 'User not found');
if (userId && String(existing.id) === String(userId)) {
throw new ApiError(400, 'You cannot delete your own account');
}
await prisma.users.update({
where: { id: BigInt(id) },
data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null, is_active: false },
});
await auditLog({
tableName: 'users',
recordId: id,
action: 'DELETE',
oldValue: sanitizeUser(existing),
newValue: { deleted_at: new Date() },
userId,
requestId,
});
};
module.exports = {
createUser,
listUsers,
getUserById,
updateUser,
deleteUser,
getScreenSummary,
getFilterOptions,
exportUsers,
};

Some files were not shown because too many files have changed in this diff Show More