Compare commits

...

11 Commits

73 changed files with 5076 additions and 652 deletions

View File

@ -1 +1 @@
import{a as s}from"./index-5rjlVIR5.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BFJfF1vG.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
import{a as s}from"./index-DzZrtI-x.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CX5oLBI_.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,15 +13,15 @@
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-5rjlVIR5.js"></script>
<script type="module" crossorigin src="/assets/index-DzZrtI-x.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BFJfF1vG.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CX5oLBI_.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-AUbBsmWB.css">
<link rel="stylesheet" crossorigin href="/assets/index-DjE6S9VF.css">
</head>
<body>

View File

@ -1,39 +1,8 @@
# =============================================================================
# RE Workflow - Full Stack Docker Compose
# Includes: Application + Database + Monitoring Stack
# =============================================================================
# Usage:
# docker-compose -f docker-compose.full.yml up -d
# =============================================================================
# docker-compose.full.yml
# Synced with streamlined infrastructure
version: '3.8'
services:
# ===========================================================================
# APPLICATION SERVICES
# ===========================================================================
postgres:
image: postgres:16-alpine
container_name: re_workflow_db
environment:
POSTGRES_USER: ${DB_USER:-laxman}
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: re_workflow_redis
@ -50,70 +19,24 @@ services:
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
container_name: re_workflow_backend
clamav:
image: clamav/clamav:latest
container_name: re_clamav
ports:
- "3310:3310"
volumes:
- clamav_data:/var/lib/clamav
environment:
NODE_ENV: development
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-laxman}
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
PORT: 5000
# Loki for logging
LOKI_HOST: http://loki:3100
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
- CLAMAV_NO_FRESHCLAMD=false
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 5
start_period: 120s
restart: unless-stopped
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ===========================================================================
# MONITORING SERVICES
# ===========================================================================
prometheus:
image: prom/prometheus:v2.47.2
container_name: re_prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
loki:
image: grafana/loki:2.9.2
@ -156,15 +79,12 @@ services:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
- GF_USERS_ALLOW_SIGN_UP=false
- GF_FEATURE_TOGGLES_ENABLE=publicDashboards
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource,grafana-piechart-panel
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
- loki
networks:
- re_workflow_network
@ -175,54 +95,13 @@ services:
timeout: 10s
retries: 3
node-exporter:
image: prom/node-exporter:v1.6.1
container_name: re_node_exporter
ports:
- "9100:9100"
networks:
- re_workflow_network
restart: unless-stopped
volumes:
redis_data:
clamav_data:
loki_data:
promtail_data:
grafana_data:
alertmanager:
image: prom/alertmanager:v0.26.0
container_name: re_alertmanager
ports:
- "9093:9093"
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
networks:
- re_workflow_network
restart: unless-stopped
# ===========================================================================
# NETWORKS
# ===========================================================================
networks:
re_workflow_network:
driver: bridge
name: re_workflow_network
# ===========================================================================
# VOLUMES
# ===========================================================================
volumes:
postgres_data:
name: re_postgres_data
redis_data:
name: re_redis_data
prometheus_data:
name: re_prometheus_data
loki_data:
name: re_loki_data
promtail_data:
name: re_promtail_data
grafana_data:
name: re_grafana_data
alertmanager_data:
name: re_alertmanager_data

View File

@ -1,28 +1,8 @@
# docker-compose.yml
# Streamlined infrastructure for local development
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: re_workflow_db
environment:
POSTGRES_USER: ${DB_USER:-laxman}
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: re_workflow_redis
@ -39,43 +19,88 @@ services:
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
container_name: re_workflow_backend
environment:
NODE_ENV: development
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-laxman}
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
PORT: 5000
clamav:
image: clamav/clamav:latest
container_name: re_clamav
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
- "3310:3310"
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
- clamav_data:/var/lib/clamav
environment:
- CLAMAV_NO_FRESHCLAMD=false
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 5
start_period: 120s
restart: unless-stopped
networks:
- re_workflow_network
loki:
image: grafana/loki:2.9.2
container_name: re_loki
ports:
- "3100:3100"
volumes:
- ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 5
promtail:
image: grafana/promtail:2.9.2
container_name: re_promtail
volumes:
- ./monitoring/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- ./logs:/var/log/app:ro
- promtail_data:/tmp/promtail
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
networks:
- re_workflow_network
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.2
container_name: re_grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- loki
networks:
- re_workflow_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:
redis_data:
clamav_data:
loki_data:
promtail_data:
grafana_data:
networks:
re_workflow_network:

View File

@ -52,6 +52,8 @@ scrape_configs:
metrics_path: /metrics
scrape_interval: 10s
scrape_timeout: 5s
authorization:
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
# ============================================
# Node Exporter - Host Metrics

194
package-lock.json generated
View File

@ -16,6 +16,7 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.63.0",
"clamscan": "^2.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.19",
@ -37,6 +38,7 @@
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",
"puppeteer": "^24.37.2",
"sanitize-html": "^2.17.1",
"sequelize": "^6.37.5",
"socket.io": "^4.8.1",
"uuid": "^8.3.2",
@ -59,6 +61,7 @@
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.19.1",
@ -3940,6 +3943,16 @@
"node": ">= 0.12"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
"integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -5316,6 +5329,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/clamscan": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz",
"integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -5761,7 +5783,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5888,6 +5909,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -6124,6 +6200,18 @@
}
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -6206,7 +6294,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -7683,6 +7770,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@ -7995,6 +8101,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -9426,6 +9541,24 @@
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -9915,6 +10048,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -10257,6 +10396,34 @@
"node": ">=12"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -10898,6 +11065,20 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -11451,6 +11632,15 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",

View File

@ -30,6 +30,7 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.63.0",
"clamscan": "^2.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.19",
@ -51,6 +52,7 @@
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",
"puppeteer": "^24.37.2",
"sanitize-html": "^2.17.1",
"sequelize": "^6.37.5",
"socket.io": "^4.8.1",
"uuid": "^8.3.2",
@ -73,6 +75,7 @@
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.19.1",
@ -94,4 +97,4 @@
"node": ">=22.0.0",
"npm": ">=10.0.0"
}
}
}

View File

@ -13,6 +13,8 @@ import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.mi
import routes from './routes/index';
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
import { rateLimiter } from './middlewares/rateLimiter.middleware';
import path from 'path';
// Load environment variables from .env file first
@ -25,7 +27,7 @@ const app: express.Application = express();
// 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc.
// This handles a specific Express/Helmet edge case where redirects lose headers.
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
const isDev = process.env.NODE_ENV !== 'production';
const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'local';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
// Build connect-src dynamically
@ -49,8 +51,8 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
"base-uri 'self'",
"default-src 'none'",
`connect-src ${connectSrc.join(' ')}`,
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='",
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='",
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='",
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='",
"style-src-attr 'unsafe-inline'",
"script-src 'self'",
"script-src-elem 'self'",
@ -114,6 +116,12 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Global rate limiting disabled — nginx handles rate limiting in production
// app.use(rateLimiter);
// HTML sanitization - strip all tags from text inputs (after body parsing, before routes)
app.use(sanitizationMiddleware);
// Logging middleware
app.use(morgan('combined'));
@ -140,6 +148,15 @@ app.use('/api/v1', routes);
ensureUploadDir();
app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR));
// Initialize ClamAV toggle manager
import { initializeToggleFile } from './services/clamav/clamavToggleManager';
try {
initializeToggleFile();
console.log(`✅ ClamAV toggle initialized (ENABLE_CLAMAV=${process.env.ENABLE_CLAMAV || 'true'})`);
} catch (err) {
console.warn('⚠️ ClamAV toggle initialization warning:', err);
}
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility)
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
try {

View File

@ -0,0 +1,79 @@
import { Request, Response } from 'express';
import { ApiTokenService } from '../services/apiToken.service';
import { ResponseHandler } from '../utils/responseHandler';
import { AuthenticatedRequest } from '../types/express';
import { z } from 'zod';
const createTokenSchema = z.object({
name: z.string().min(1).max(100),
expiresInDays: z.number().int().positive().optional(),
});
export class ApiTokenController {
private apiTokenService: ApiTokenService;
constructor() {
this.apiTokenService = new ApiTokenService();
}
/**
* Create a new API Token
*/
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const validation = createTokenSchema.safeParse(req.body);
if (!validation.success) {
ResponseHandler.error(res, 'Validation error', 400, validation.error.message);
return;
}
const { name, expiresInDays } = validation.data;
const userId = req.user.userId;
const result = await this.apiTokenService.createToken(userId, name, expiresInDays);
ResponseHandler.success(res, {
token: result.token,
apiToken: result.apiToken
}, 'API Token created successfully. Please copy the token now, you will not be able to see it again.');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to create API token', 500, errorMessage);
}
}
/**
* List user's API Tokens
*/
async list(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user.userId;
const tokens = await this.apiTokenService.listTokens(userId);
ResponseHandler.success(res, { tokens }, 'API Tokens retrieved successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to list API tokens', 500, errorMessage);
}
}
/**
* Revoke an API Token
*/
async revoke(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user.userId;
const { id } = req.params;
const success = await this.apiTokenService.revokeToken(userId, id);
if (success) {
ResponseHandler.success(res, null, 'API Token revoked successfully');
} else {
ResponseHandler.notFound(res, 'Token not found or already revoked');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to revoke API token', 500, errorMessage);
}
}
}

View File

@ -4,7 +4,7 @@ import { aiService } from '@services/ai.service';
import { activityService } from '@services/activity.service';
import logger from '@utils/logger';
import { getRequestMetadata } from '@utils/requestUtils';
import { sanitizeHtml } from '@utils/sanitizer';
export class ConclusionController {
/**
@ -249,11 +249,11 @@ export class ConclusionController {
}
// Update conclusion
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS)
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
const sanitizedRemark = sanitizeHtml(finalRemark);
await conclusion.update({
finalRemark: sanitizedRemark,
finalRemark: finalRemark,
editedBy: userId,
isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
@ -285,7 +285,7 @@ export class ConclusionController {
return res.status(400).json({ error: 'Final remark is required' });
}
const sanitizedRemark = sanitizeHtml(finalRemark);
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS)
// Fetch request
const request = await WorkflowRequest.findOne({
@ -319,7 +319,7 @@ export class ConclusionController {
aiGeneratedRemark: null,
aiModelUsed: null,
aiConfidenceScore: null,
finalRemark: sanitizedRemark,
finalRemark: finalRemark,
editedBy: userId,
isEdited: false,
editCount: 0,
@ -334,7 +334,7 @@ export class ConclusionController {
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
await conclusion.update({
finalRemark: sanitizedRemark,
finalRemark: finalRemark,
editedBy: userId,
isEdited: wasEdited,
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
@ -345,7 +345,7 @@ export class ConclusionController {
// Update request status to CLOSED
await request.update({
status: 'CLOSED',
conclusionRemark: sanitizedRemark,
conclusionRemark: finalRemark,
closureDate: new Date()
} as any);

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import type { AuthenticatedRequest } from '../types/express';
import { DealerClaimService } from '../services/dealerClaim.service';
import { ResponseHandler } from '../utils/responseHandler';
import { translateEInvoiceError } from '../utils/einvoiceErrors';
import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document';
@ -11,6 +12,11 @@ import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { ActivityType } from '../models/ActivityType';
export class DealerClaimController {
private dealerClaimService = new DealerClaimService();
@ -751,7 +757,11 @@ export class DealerClaimController {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating e-invoice:', error);
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
// Translate technical PWC/IRP error codes to user-friendly messages
const userFacingMessage = translateEInvoiceError(errorMessage);
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
}
}
@ -781,31 +791,28 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Invoice record not found', 404);
}
// Automatically regenerate PDF to ensure latest template/data is used (useful during testing/fixes)
// Generate PDF on the fly
try {
const { pdfService } = await import('../services/pdf.service');
await pdfService.generateInvoicePdf(requestId);
// Re-fetch invoice to get the new filePath
invoice = await ClaimInvoice.findOne({ where: { requestId } });
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
const requestNumber = workflow.requestNumber || 'invoice';
const fileName = `Invoice_${requestNumber}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
res.setHeader('Content-Length', pdfBuffer.length);
// Convert Buffer to stream
const { Readable } = await import('stream');
const stream = new Readable();
stream.push(pdfBuffer);
stream.push(null);
stream.pipe(res);
} catch (pdfError) {
logger.error(`[DealerClaimController] Failed to auto-regenerate PDF:`, pdfError);
// Continue with existing file if regeneration fails
logger.error(`[DealerClaimController] Failed to generate PDF:`, pdfError);
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
}
if (!invoice || !invoice.filePath) {
return ResponseHandler.error(res, 'Invoice PDF not found', 404);
}
const filePath = path.join(process.cwd(), 'storage', 'invoices', invoice.filePath);
if (!fs.existsSync(filePath)) {
return ResponseHandler.error(res, 'Invoice file not found on server', 404);
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${invoice.filePath}"`);
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice PDF:', error);
@ -981,5 +988,98 @@ export class DealerClaimController {
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
}
}
}
/**
* Download Invoice CSV
* GET /api/v1/dealer-claims/:requestId/e-invoice/csv
*/
async downloadInvoiceCsv(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId;
// Use helper to find workflow
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
// Fetch related data
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
ClaimInvoice.findOne({ where: { requestId } }),
ClaimInvoiceItem.findAll({ where: { requestId }, order: [['slNo', 'ASC']] }),
DealerClaimDetails.findOne({ where: { requestId } }),
InternalOrder.findOne({ where: { requestId } })
]);
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
let sapRefNo = '';
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
// Construct CSV
const headers = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_DATE',
'CLAIM_AMT',
'GST_AMT',
'GST_PERCENTAG'
];
const rows = items.map(item => {
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = claimDetails?.dealerCode || '';
const ioNumber = internalOrder?.ioNumber || '';
const claimDocTyp = sapRefNo;
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
// Zero out tax for Non-GST
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
return [
trnsUniqNo,
claimNumber,
invNumber,
dealerCode,
ioNumber,
claimDocTyp,
claimDate,
claimAmt,
totalTax.toFixed(2),
gstPercentag
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
res.status(200).send(csvContent);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice CSV:', error);
return ResponseHandler.error(res, 'Failed to download invoice CSV', 500, errorMessage);
}
}
}

View File

@ -3,6 +3,9 @@ import jwt from 'jsonwebtoken';
import { User } from '../models/User';
import { ssoConfig } from '../config/sso';
import { ResponseHandler } from '../utils/responseHandler';
import { ApiTokenService } from '../services/apiToken.service';
const apiTokenService = new ApiTokenService();
interface JwtPayload {
userId: string;
@ -23,6 +26,29 @@ export const authenticateToken = async (
const authHeader = req.headers.authorization;
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
// Check if it's an API Token (starts with re_)
if (token && token.startsWith('re_')) {
const user = await apiTokenService.verifyToken(token);
if (!user || !user.isActive) {
ResponseHandler.unauthorized(res, 'Invalid or expired API token');
return;
}
// Attach user info to request object
req.user = {
userId: user.userId,
email: user.email,
employeeId: user.employeeId || null,
role: user.role
};
next();
return;
}
// Fallback to cookie if available (requires cookie-parser middleware)
// Fallback to cookie if available (requires cookie-parser middleware)
if (!token && req.cookies?.accessToken) {
token = req.cookies.accessToken;
@ -35,10 +61,10 @@ export const authenticateToken = async (
// Verify JWT token
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
// Fetch user from database to ensure they still exist and are active
const user = await User.findByPk(decoded.userId);
if (!user || !user.isActive) {
ResponseHandler.unauthorized(res, 'User not found or inactive');
return;
@ -89,7 +115,7 @@ export const optionalAuth = async (
if (token) {
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
const user = await User.findByPk(decoded.userId);
if (user && user.isActive) {
req.user = {
userId: user.userId,
@ -99,7 +125,7 @@ export const optionalAuth = async (
};
}
}
next();
} catch (error) {
// For optional auth, we don't throw errors, just continue without user

View File

@ -0,0 +1,433 @@
/**
* Malware Scan Middleware
* Express middleware that intercepts file uploads, triggers ClamAV scan,
* and blocks infected files. Uses temp file approach to work with memory storage.
*
* Flow:
* multer (memory storage) malwareScanMiddleware controller
*
* Write buffer to temp file ClamAV scan Delete temp file
*
* Clean attach result to req, call next()
* Infected return 403
* Scan error return 503 (fail-secure)
* Skipped (disabled) log, call next()
*/
import { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { scanFile, ClamScanResult } from '../services/clamav/clamavScanWrapper';
import { scanContentForXSS, ContentScanResult } from '../services/fileUpload/contentXSSScanner';
import { logSecurityEvent, SecurityEventType } from '../services/logging/securityEventLogger';
import { validateFile } from '../services/fileUpload/fileValidationService';
// ── Extend Express Request ──
declare global {
namespace Express {
interface Request {
malwareScanResult?: ClamScanResult;
contentScanResult?: ContentScanResult;
scanEventId?: string;
}
}
}
// ── Temp file helpers ──
function writeTempFile(buffer: Buffer, originalName: string): string {
const tempDir = path.join(os.tmpdir(), 'clamav-scan');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const ext = path.extname(originalName);
const tempPath = path.join(tempDir, `${uuidv4()}${ext}`);
fs.writeFileSync(tempPath, buffer);
return tempPath;
}
function deleteTempFile(tempPath: string): void {
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (error) {
console.error('[MalwareScan] Failed to delete temp file:', tempPath, error);
}
}
// ── Middleware ──
/**
* Malware scan middleware for single file uploads (multer.single)
* Works with memory storage writes buffer to temp scans deletes temp
*/
export function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): void {
// Skip if no file uploaded
const file = req.file;
if (!file) {
console.log('[MalwareScan] No file attached — skipping scan');
return next();
}
console.log(`[MalwareScan] 🔒 Scanning single file: ${file.originalname} (${file.size} bytes, ${file.mimetype})`);
const scanEventId = uuidv4();
req.scanEventId = scanEventId;
// Handle the async scan
performScan(file, scanEventId, req, res, next);
}
/**
* Malware scan middleware for multiple file uploads (multer.array / multer.fields)
* Scans all files and blocks if ANY file is infected
*/
export function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): void {
// Handle multer.array()
const files = req.files;
if (!files || (Array.isArray(files) && files.length === 0)) {
console.log('[MalwareScan] No files attached — skipping multi-scan');
return next();
}
console.log(`[MalwareScan] 🔒 Multi-file scan started`);
const scanEventId = uuidv4();
req.scanEventId = scanEventId;
// Handle array of files
if (Array.isArray(files)) {
performMultiScan(files, scanEventId, req, res, next);
return;
}
// Handle multer.fields() — object with field names as keys
const allFiles: Express.Multer.File[] = [];
const filesObj = files as { [fieldname: string]: Express.Multer.File[] };
for (const fieldFiles of Object.values(filesObj)) {
allFiles.push(...fieldFiles);
}
if (allFiles.length === 0) {
return next();
}
performMultiScan(allFiles, scanEventId, req, res, next);
}
// ── Core scan logic ──
async function performScan(
file: Express.Multer.File,
scanEventId: string,
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
let tempPath: string | null = null;
try {
// Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns)
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
file.originalname,
file.mimetype,
file.buffer || null,
file.size,
maxSizeMB,
);
if (!validation.valid) {
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
reason: 'FILE_VALIDATION_FAILED',
errors: validation.errors,
});
res.status(403).json({
success: false,
error: 'FILE_VALIDATION_FAILED',
message: `File rejected: ${validation.errors[0]}`,
scanEventId,
details: {
errors: validation.errors,
warnings: validation.warnings,
},
});
return;
}
if (validation.warnings.length > 0) {
console.log(`[MalwareScan] ⚠️ File validation warnings for "${file.originalname}": ${validation.warnings.join('; ')}`);
}
// 🟢 SANITIZATION: Update originalname with the sanitized version
if (validation.sanitizedFilename) {
file.originalname = validation.sanitizedFilename;
}
// Check if we have a buffer (memory storage) or path (disk storage)
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);
} else if (file.path) {
tempPath = file.path;
} else {
console.warn('[MalwareScan] No file buffer or path available, skipping scan');
return next();
}
// 1. ClamAV Malware Scan
const malwareResult = await scanFile(tempPath);
req.malwareScanResult = malwareResult;
// If infected, block immediately
if (malwareResult.isInfected) {
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
virusNames: malwareResult.virusNames,
scanDuration: malwareResult.scanDuration,
});
// Delete temp file
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(403).json({
success: false,
error: 'MALWARE_DETECTED',
message: 'File contains malware and was blocked',
scanEventId,
details: {
scanEngine: 'ClamAV',
signatures: malwareResult.virusNames,
},
});
return;
}
// If ClamAV had an error (not skipped, but failed to scan), fail-secure
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(503).json({
success: false,
error: 'SCAN_UNAVAILABLE',
message: 'Antivirus scanning is temporarily unavailable. Please try again later.',
scanEventId,
});
return;
}
// 2. Content XSS Scan
const contentToScan = file.buffer || fs.readFileSync(tempPath);
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
req.contentScanResult = contentResult;
// If XSS threats found, block
if (!contentResult.safe) {
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
scanEventId,
originalName: file.originalname,
threats: contentResult.threats.map(t => t.description),
severity: contentResult.severity,
scanType: contentResult.scanType,
});
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(403).json({
success: false,
error: 'CONTENT_THREAT_DETECTED',
message: 'File contains potentially malicious content and was blocked',
scanEventId,
details: {
scanType: contentResult.scanType,
threats: contentResult.threats.map(t => ({
description: t.description,
severity: t.severity,
})),
overallSeverity: contentResult.severity,
},
});
return;
}
// Clean temp file (only if we created it)
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
// All clear — proceed to controller
next();
} catch (error: any) {
console.error('[MalwareScan] Unexpected error during scan:', error.message);
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
// Fail-secure on unexpected errors
res.status(503).json({
success: false,
error: 'SCAN_ERROR',
message: 'An error occurred during security scanning. Please try again.',
scanEventId,
});
}
}
async function performMultiScan(
files: Express.Multer.File[],
scanEventId: string,
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
for (const file of files) {
let tempPath: string | null = null;
// Step 0: Pre-scan file validation
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
file.originalname,
file.mimetype,
file.buffer || null,
file.size,
maxSizeMB,
);
if (!validation.valid) {
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
reason: 'FILE_VALIDATION_FAILED',
errors: validation.errors,
});
res.status(403).json({
success: false,
error: 'FILE_VALIDATION_FAILED',
message: `File "${file.originalname}" rejected: ${validation.errors[0]}`,
scanEventId,
details: { errors: validation.errors, warnings: validation.warnings },
});
return;
}
// 🟢 SANITIZATION: Update originalname with the sanitized version for each file in the array
if (validation.sanitizedFilename) {
file.originalname = validation.sanitizedFilename;
}
// Write to temp if memory storage
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);
} else if (file.path) {
tempPath = file.path;
} else {
continue; // Skip files without buffer or path
}
// ClamAV scan
const malwareResult = await scanFile(tempPath);
if (malwareResult.isInfected) {
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
virusNames: malwareResult.virusNames,
});
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(403).json({
success: false,
error: 'MALWARE_DETECTED',
message: `File "${file.originalname}" contains malware and was blocked`,
scanEventId,
details: {
scanEngine: 'ClamAV',
signatures: malwareResult.virusNames,
infectedFile: file.originalname,
},
});
return;
}
// ClamAV error — fail-secure
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(503).json({
success: false,
error: 'SCAN_UNAVAILABLE',
message: 'Antivirus scanning is temporarily unavailable.',
scanEventId,
});
return;
}
// Content XSS scan
const contentToScan = file.buffer || fs.readFileSync(tempPath);
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
if (!contentResult.safe) {
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
scanEventId,
originalName: file.originalname,
threats: contentResult.threats.map(t => t.description),
severity: contentResult.severity,
});
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(403).json({
success: false,
error: 'CONTENT_THREAT_DETECTED',
message: `File "${file.originalname}" contains potentially malicious content`,
scanEventId,
details: {
scanType: contentResult.scanType,
threats: contentResult.threats.map(t => ({
description: t.description,
severity: t.severity,
})),
},
});
return;
}
// Clean temp file
if (tempPath && file.buffer) deleteTempFile(tempPath);
}
// All files clean
next();
} catch (error: any) {
console.error('[MalwareScan] Error during multi-file scan:', error.message);
res.status(503).json({
success: false,
error: 'SCAN_ERROR',
message: 'An error occurred during security scanning.',
scanEventId,
});
}
}

View File

@ -180,6 +180,58 @@ export const queueJobProcessingRate = new client.Gauge({
registers: [register],
});
// ============================================================================
// ANTIVIRUS / SECURITY METRICS
// ============================================================================
// ClamAV scan results counter
export const antivirusScanTotal = new client.Counter({
name: 'antivirus_scan_total',
help: 'Total number of antivirus scans performed',
labelNames: ['result'], // clean, infected, error, skipped
registers: [register],
});
// ClamAV scan duration histogram
export const antivirusScanDuration = new client.Histogram({
name: 'antivirus_scan_duration_seconds',
help: 'ClamAV scan duration in seconds',
buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
registers: [register],
});
// XSS content scan results counter
export const contentXssScanTotal = new client.Counter({
name: 'content_xss_scan_total',
help: 'Total number of content XSS scans performed',
labelNames: ['result', 'file_type'], // safe, threat
registers: [register],
});
// ClamAV daemon status gauge (1 = up, 0 = down)
export const clamavDaemonStatus = new client.Gauge({
name: 'clamav_daemon_status',
help: 'ClamAV daemon health status (1=up, 0=down)',
registers: [register],
});
// Helper functions for recording antivirus metrics
export function recordAntivirusScan(result: 'clean' | 'infected' | 'error' | 'skipped', durationMs?: number): void {
antivirusScanTotal.inc({ result });
if (durationMs !== undefined) {
antivirusScanDuration.observe(durationMs / 1000);
}
}
export function recordContentXssScan(result: 'safe' | 'threat', fileType: string): void {
contentXssScanTotal.inc({ result, file_type: fileType });
}
export function updateClamavDaemonStatus(isUp: boolean): void {
clamavDaemonStatus.set(isUp ? 1 : 0);
}
// ============================================================================
// MIDDLEWARE
// ============================================================================
@ -271,10 +323,10 @@ export async function metricsHandler(_req: Request, res: Response): Promise<void
*/
export function createMetricsRouter(): Router {
const router = Router();
// Metrics endpoint (GET /metrics)
router.get('/metrics', metricsHandler);
return router;
}

View File

@ -1,8 +1,12 @@
import rateLimit from 'express-rate-limit';
/**
* General rate limiter applied globally to all API routes.
* Configurable via environment variables.
*/
export const rateLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000', 10),
message: {
success: false,
message: 'Too many requests from this IP, please try again later.',
@ -10,4 +14,117 @@ export const rateLimiter = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
});
skip: (req) => req.path === '/health' || req.path === '/api/v1/health',
});
/**
* Stricter rate limiter for authentication routes (login, token exchange).
* Prevents brute-force attacks on auth endpoints.
*/
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: {
success: false,
message: 'Too many authentication attempts. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for file upload routes.
* Prevents upload abuse / DoS.
*/
export const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 500,
message: {
success: false,
message: 'Too many upload requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for admin routes.
* Stricter to prevent admin action abuse.
*/
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300,
message: {
success: false,
message: 'Too many admin requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for SAP / PWC e-invoice integration routes.
* Very strict SAP and PWC calls are expensive external API calls.
*/
export const sapLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: {
success: false,
message: 'Too many SAP/e-invoice requests. Please wait before trying again.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for AI routes.
* AI calls are resource-intensive limit accordingly.
*/
export const aiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: {
success: false,
message: 'Too many AI requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for DMS webhook routes.
* Webhooks may come in bursts, allow higher throughput.
*/
export const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000,
message: {
success: false,
message: 'Too many webhook requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* General API rate limiter for standard routes.
* Applied to routes without a more specific limiter.
*/
export const generalApiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 2000,
message: {
success: false,
message: 'Too many requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});

View File

@ -0,0 +1,171 @@
/**
* Sanitization Middleware
* Sanitizes string inputs in req.body and req.query to prevent stored XSS.
*
* Uses TWO strategies:
* 1. STRICT strips ALL HTML tags (for normal text fields like names, emails, titles)
* 2. PERMISSIVE allows safe formatting tags (for rich text fields like description, message, comments)
*
* This middleware runs AFTER body parsing and BEFORE route handlers.
* File upload routes (multipart) are skipped those are handled
* by the malwareScan middleware pipeline.
*/
import { Request, Response, NextFunction } from 'express';
import sanitizeHtml from 'sanitize-html';
/**
* Fields that intentionally store HTML from rich text editors.
* These get PERMISSIVE sanitization (safe formatting tags allowed).
* All other string fields get STRICT sanitization (all tags stripped).
*/
const RICH_TEXT_FIELDS = new Set([
'description',
'requestDescription',
'message',
'content',
'comments',
'rejectionReason',
'pauseReason',
'conclusionRemark',
'aiGeneratedRemark',
'finalRemark',
'closingRemarks',
'effectiveFinalRemark',
'keyDiscussionPoints',
'keyPoints',
'remarksText',
'remark',
'remarks',
'feedback',
'note',
'notes',
'skipReason',
]);
// Strict config: zero allowed tags, zero allowed attributes
const strictSanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: [],
allowedAttributes: {},
allowedIframeHostnames: [],
disallowedTagsMode: 'discard',
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object'],
};
// Permissive config: allow safe formatting tags from rich text editors
// Blocks dangerous elements (script, iframe, object, embed, form, input)
const permissiveSanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: [
// Text formatting
'p', 'br', 'b', 'i', 'u', 'em', 'strong', 's', 'strike', 'del', 'sub', 'sup', 'mark', 'small',
// Headings
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Block elements
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
// Links (href checked below)
'a',
// Images (src checked below)
'img',
],
allowedAttributes: {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'td': ['colspan', 'rowspan', 'style'],
'th': ['colspan', 'rowspan', 'style'],
'span': ['class', 'style'],
'div': ['class', 'style'],
'pre': ['class', 'style'],
'code': ['class', 'style'],
'p': ['class', 'style'],
'h1': ['class', 'style'],
'h2': ['class', 'style'],
'h3': ['class', 'style'],
'h4': ['class', 'style'],
'h5': ['class', 'style'],
'h6': ['class', 'style'],
'ul': ['class', 'style'],
'ol': ['class', 'style', 'start', 'type'],
'li': ['class', 'style'],
'blockquote': ['class', 'style'],
'table': ['class', 'style'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedIframeHostnames: [],
disallowedTagsMode: 'discard',
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object', 'applet', 'form', 'input', 'textarea', 'select', 'button'],
};
/**
* Recursively sanitize all string values in an object or array
* Uses the field key to decide strict vs permissive sanitization
*/
function sanitizeValue(value: any, fieldKey?: string): any {
if (typeof value === 'string') {
const isRichTextField = fieldKey && RICH_TEXT_FIELDS.has(fieldKey);
const config = isRichTextField ? permissiveSanitizeConfig : strictSanitizeConfig;
return sanitizeHtml(value, config);
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, fieldKey));
}
if (value !== null && typeof value === 'object') {
return sanitizeObject(value);
}
return value;
}
/**
* Sanitize all string properties of an object (recursively)
* Passes the key name to sanitizeValue so it can choose the right config
*/
function sanitizeObject(obj: Record<string, any>): Record<string, any> {
const sanitized: Record<string, any> = {};
for (const key of Object.keys(obj)) {
sanitized[key] = sanitizeValue(obj[key], key);
}
return sanitized;
}
/**
* Express middleware that sanitizes req.body and req.query
* Skips multipart/form-data requests (file uploads handled by malwareScan)
*/
export const sanitizationMiddleware = (req: Request, _res: Response, next: NextFunction): void => {
try {
// Skip multipart requests — file uploads are sanitized by the malware scan pipeline
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
return next();
}
// Sanitize req.body (POST/PUT/PATCH payloads)
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
req.body = sanitizeObject(req.body);
}
// Sanitize req.query (GET query parameters) — always strict (no HTML in query params)
if (req.query && typeof req.query === 'object' && Object.keys(req.query).length > 0) {
const strictQuery: Record<string, any> = {};
for (const key of Object.keys(req.query)) {
const val = req.query[key];
if (typeof val === 'string') {
strictQuery[key] = sanitizeHtml(val, strictSanitizeConfig);
} else {
strictQuery[key] = val;
}
}
req.query = strictQuery as any;
}
next();
} catch (error) {
// If sanitization fails for any reason, don't block the request —
// downstream validation (Zod) will catch malformed input
console.warn('Sanitization middleware warning:', error instanceof Error ? error.message : 'Unknown error');
next();
}
};

View File

@ -0,0 +1,50 @@
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Helper function to check if a column exists in a table
*/
async function columnExists(
queryInterface: QueryInterface,
tableName: string,
columnName: string
): Promise<boolean> {
try {
const tableDescription = await queryInterface.describeTable(tableName);
return columnName in tableDescription;
} catch (error) {
return false;
}
}
export async function up(queryInterface: QueryInterface): Promise<void> {
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
const newColumns = {
quantity: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 },
hsn_code: { type: DataTypes.STRING(20), allowNull: true }
};
for (const table of tables) {
for (const [colName, colSpec] of Object.entries(newColumns)) {
if (!(await columnExists(queryInterface, table, colName))) {
await queryInterface.addColumn(table, colName, colSpec);
console.log(`Added column ${colName} to ${table}`);
} else {
console.log(`Column ${colName} already exists in ${table}`);
}
}
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
const columns = ['quantity', 'hsn_code'];
for (const table of tables) {
for (const col of columns) {
await queryInterface.removeColumn(table, col).catch((err) => {
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
});
}
}
}

View File

@ -0,0 +1,70 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('api_tokens', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false,
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'user_id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: 'User-friendly name for the token',
},
prefix: {
type: DataTypes.STRING(10),
allowNull: false,
comment: 'First few characters of token for identification (e.g., re_1234)',
},
token_hash: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Bcrypt hash of the full token',
},
last_used_at: {
type: DataTypes.DATE,
allowNull: true,
},
expires_at: {
type: DataTypes.DATE,
allowNull: true,
comment: 'Optional expiration date',
},
is_active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
allowNull: false,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
// Indexes
await queryInterface.addIndex('api_tokens', ['user_id']);
await queryInterface.addIndex('api_tokens', ['prefix']);
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('api_tokens');
}

View File

@ -0,0 +1,43 @@
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Helper function to check if a column exists in a table
*/
async function columnExists(
queryInterface: QueryInterface,
tableName: string,
columnName: string
): Promise<boolean> {
try {
const tableDescription = await queryInterface.describeTable(tableName);
return columnName in tableDescription;
} catch (error) {
return false;
}
}
export async function up(queryInterface: QueryInterface): Promise<void> {
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
const colName = 'is_service';
const colSpec = { type: DataTypes.BOOLEAN, allowNull: true, defaultValue: false };
for (const table of tables) {
if (!(await columnExists(queryInterface, table, colName))) {
await queryInterface.addColumn(table, colName, colSpec);
console.log(`Added column ${colName} to ${table}`);
} else {
console.log(`Column ${colName} already exists in ${table}`);
}
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
const col = 'is_service';
for (const table of tables) {
await queryInterface.removeColumn(table, col).catch((err) => {
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
});
}
}

View File

@ -0,0 +1,134 @@
import { QueryInterface, DataTypes } from 'sequelize';
module.exports = {
up: async (queryInterface: QueryInterface) => {
await queryInterface.createTable('claim_invoice_items', {
item_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoice_number: {
type: DataTypes.STRING(100),
allowNull: true,
},
transaction_code: {
type: DataTypes.STRING(100),
allowNull: true,
},
sl_no: {
type: DataTypes.INTEGER,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: false,
},
hsn_cd: {
type: DataTypes.STRING(20),
allowNull: false,
},
qty: {
type: DataTypes.DECIMAL(15, 3),
allowNull: false,
},
unit: {
type: DataTypes.STRING(20),
allowNull: false,
},
unit_price: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
ass_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
gst_rt: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
},
igst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
cgst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
sgst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
utgst_amt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
},
cgst_rate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
},
sgst_rate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
},
igst_rate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
},
utgst_rate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
},
tot_item_val: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
},
is_servc: {
type: DataTypes.STRING(1),
allowNull: false,
},
expense_ids: {
type: DataTypes.JSONB,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
});
// Add indexes
await queryInterface.addIndex('claim_invoice_items', ['request_id'], {
name: 'idx_claim_invoice_items_request_id',
});
await queryInterface.addIndex('claim_invoice_items', ['invoice_number'], {
name: 'idx_claim_invoice_items_invoice_number',
});
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('claim_invoice_items');
},
};

105
src/models/ApiToken.ts Normal file
View File

@ -0,0 +1,105 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
import { User } from './User';
interface ApiTokenAttributes {
id: string;
userId: string;
name: string;
prefix: string;
tokenHash: string;
lastUsedAt?: Date | null;
expiresAt?: Date | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
interface ApiTokenCreationAttributes extends Optional<ApiTokenAttributes, 'id' | 'lastUsedAt' | 'expiresAt' | 'isActive' | 'createdAt' | 'updatedAt'> { }
class ApiToken extends Model<ApiTokenAttributes, ApiTokenCreationAttributes> implements ApiTokenAttributes {
public id!: string;
public userId!: string;
public name!: string;
public prefix!: string;
public tokenHash!: string;
public lastUsedAt?: Date | null;
public expiresAt?: Date | null;
public isActive!: boolean;
public createdAt!: Date;
public updatedAt!: Date;
}
ApiToken.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
field: 'user_id',
references: {
model: 'users',
key: 'user_id',
},
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
},
prefix: {
type: DataTypes.STRING(10),
allowNull: false,
},
tokenHash: {
type: DataTypes.STRING,
allowNull: false,
field: 'token_hash',
},
lastUsedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_used_at',
},
expiresAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'expires_at',
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
field: 'is_active',
allowNull: false,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
modelName: 'ApiToken',
tableName: 'api_tokens',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
}
);
// Define associations
ApiToken.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(ApiToken, { foreignKey: 'userId', as: 'apiTokens' });
export { ApiToken };

View File

@ -0,0 +1,231 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
interface ClaimInvoiceItemAttributes {
itemId: string;
requestId: string;
invoiceNumber?: string;
transactionCode?: string;
slNo: number;
description: string;
hsnCd: string;
qty: number;
unit: string;
unitPrice: number;
assAmt: number;
gstRt: number;
igstAmt: number;
cgstAmt: number;
sgstAmt: number;
utgstAmt: number;
totItemVal: number;
isServc: string;
igstRate?: number;
cgstRate?: number;
sgstRate?: number;
utgstRate?: number;
expenseIds?: string[];
createdAt: Date;
updatedAt: Date;
}
interface ClaimInvoiceItemCreationAttributes extends Optional<ClaimInvoiceItemAttributes, 'itemId' | 'invoiceNumber' | 'transactionCode' | 'expenseIds' | 'createdAt' | 'updatedAt' | 'utgstAmt' | 'igstRate' | 'cgstRate' | 'sgstRate' | 'utgstRate'> { }
class ClaimInvoiceItem extends Model<ClaimInvoiceItemAttributes, ClaimInvoiceItemCreationAttributes> implements ClaimInvoiceItemAttributes {
public itemId!: string;
public requestId!: string;
public invoiceNumber?: string;
public transactionCode?: string;
public slNo!: number;
public description!: string;
public hsnCd!: string;
public qty!: number;
public unit!: string;
public unitPrice!: number;
public assAmt!: number;
public gstRt!: number;
public igstAmt!: number;
public cgstAmt!: number;
public sgstAmt!: number;
public utgstAmt!: number;
public totItemVal!: number;
public isServc!: string;
public igstRate?: number;
public cgstRate?: number;
public sgstRate?: number;
public utgstRate?: number;
public expenseIds?: string[];
public createdAt!: Date;
public updatedAt!: Date;
}
ClaimInvoiceItem.init(
{
itemId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'item_id',
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
invoiceNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'invoice_number',
},
transactionCode: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'transaction_code',
},
slNo: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'sl_no',
},
description: {
type: DataTypes.TEXT,
allowNull: false,
field: 'description',
},
hsnCd: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'hsn_cd',
},
qty: {
type: DataTypes.DECIMAL(15, 3),
allowNull: false,
field: 'qty',
},
unit: {
type: DataTypes.STRING(20),
allowNull: false,
field: 'unit',
},
unitPrice: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'unit_price',
},
assAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'ass_amt',
},
gstRt: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
field: 'gst_rt',
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'igst_amt',
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'cgst_amt',
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'sgst_amt',
},
utgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
defaultValue: 0,
field: 'utgst_amt',
},
igstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
field: 'igst_rate',
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
field: 'cgst_rate',
},
sgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
field: 'sgst_rate',
},
utgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
defaultValue: 0,
field: 'utgst_rate',
},
totItemVal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'tot_item_val',
},
isServc: {
type: DataTypes.STRING(1),
allowNull: false,
field: 'is_servc',
},
expenseIds: {
type: DataTypes.JSONB,
allowNull: true,
field: 'expense_ids',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'updated_at',
},
},
{
sequelize,
modelName: 'ClaimInvoiceItem',
tableName: 'claim_invoice_items',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['request_id'], name: 'idx_claim_invoice_items_request_id' },
{ fields: ['invoice_number'], name: 'idx_claim_invoice_items_invoice_number' },
],
}
);
WorkflowRequest.hasMany(ClaimInvoiceItem, {
as: 'invoiceItems',
foreignKey: 'requestId',
sourceKey: 'requestId',
});
ClaimInvoiceItem.belongsTo(WorkflowRequest, {
as: 'workflowRequest',
foreignKey: 'requestId',
targetKey: 'requestId',
});
export { ClaimInvoiceItem };

View File

@ -11,6 +11,8 @@ interface DealerCompletionExpenseAttributes {
amount: number;
gstRate?: number;
gstAmt?: number;
quantity?: number;
hsnCode?: string;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
@ -22,6 +24,7 @@ interface DealerCompletionExpenseAttributes {
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
expenseDate: Date;
createdAt: Date;
updatedAt: Date;
@ -37,6 +40,8 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
@ -48,6 +53,7 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public expenseDate!: Date;
public createdAt!: Date;
public updatedAt!: Date;
@ -101,6 +107,17 @@ DealerCompletionExpense.init(
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
@ -156,6 +173,12 @@ DealerCompletionExpense.init(
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
expenseDate: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -11,6 +11,8 @@ interface DealerProposalCostItemAttributes {
amount: number;
gstRate?: number;
gstAmt?: number;
quantity?: number;
hsnCode?: string;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
@ -22,6 +24,7 @@ interface DealerProposalCostItemAttributes {
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
itemOrder: number;
createdAt: Date;
updatedAt: Date;
@ -37,6 +40,8 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
@ -48,6 +53,7 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public itemOrder!: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -102,6 +108,17 @@ DealerProposalCostItem.init(
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
@ -157,6 +174,12 @@ DealerProposalCostItem.init(
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
itemOrder: {
type: DataTypes.INTEGER,
allowNull: false,

View File

@ -1,5 +1,8 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
// ApiToken association is defined in ApiToken.ts to avoid circular dependency issues
// but we can declare the mixin type here if needed.
/**
* User Role Enum
@ -21,7 +24,7 @@ interface UserAttributes {
department?: string | null;
designation?: string | null;
phone?: string | null;
// Extended fields from SSO/Okta (All Optional)
manager?: string | null; // Reporting manager name
secondEmail?: string | null; // Alternate email
@ -30,7 +33,7 @@ interface UserAttributes {
postalAddress?: string | null; // Work location/office address
mobilePhone?: string | null; // Mobile contact (different from phone)
adGroups?: string[] | null; // Active Directory group memberships
// Location Information (JSON object)
location?: {
city?: string;
@ -39,12 +42,12 @@ interface UserAttributes {
office?: string;
timezone?: string;
};
// Notification Preferences
emailNotificationsEnabled: boolean;
pushNotificationsEnabled: boolean;
inAppNotificationsEnabled: boolean;
isActive: boolean;
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
lastLogin?: Date;
@ -52,7 +55,7 @@ interface UserAttributes {
updatedAt: Date;
}
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public userId!: string;
@ -65,7 +68,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public department?: string;
public designation?: string;
public phone?: string;
// Extended fields from SSO/Okta (All Optional)
public manager?: string | null;
public secondEmail?: string | null;
@ -74,7 +77,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public postalAddress?: string | null;
public mobilePhone?: string | null;
public adGroups?: string[] | null;
// Location Information (JSON object)
public location?: {
city?: string;
@ -83,12 +86,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
office?: string;
timezone?: string;
};
// Notification Preferences
public emailNotificationsEnabled!: boolean;
public pushNotificationsEnabled!: boolean;
public inAppNotificationsEnabled!: boolean;
public isActive!: boolean;
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
public lastLogin?: Date;
@ -96,26 +99,26 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public updatedAt!: Date;
// Associations
/**
* Helper Methods for Role Checking
*/
public isUserRole(): boolean {
return this.role === 'USER';
}
public isManagementRole(): boolean {
return this.role === 'MANAGEMENT';
}
public isAdminRole(): boolean {
return this.role === 'ADMIN';
}
public hasManagementAccess(): boolean {
return this.role === 'MANAGEMENT' || this.role === 'ADMIN';
}
public hasAdminAccess(): boolean {
return this.role === 'ADMIN';
}
@ -181,7 +184,7 @@ User.init(
type: DataTypes.STRING(20),
allowNull: true
},
// ============ Extended SSO/Okta Fields (All Optional) ============
manager: {
type: DataTypes.STRING(200),
@ -227,14 +230,14 @@ User.init(
field: 'ad_groups',
comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array'
},
// Location Information (JSON object)
location: {
type: DataTypes.JSONB, // Use JSONB for PostgreSQL
allowNull: true,
comment: 'JSON object containing location details (city, state, country, office, timezone)'
},
// Notification Preferences
emailNotificationsEnabled: {
type: DataTypes.BOOLEAN,
@ -257,7 +260,7 @@ User.init(
field: 'in_app_notifications_enabled',
comment: 'User preference for receiving in-app notifications'
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,

View File

@ -26,6 +26,9 @@ import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
import { DealerClaimHistory } from './DealerClaimHistory';
import { WorkflowTemplate } from './WorkflowTemplate';
import { ClaimInvoice } from './ClaimInvoice';
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
import { ClaimCreditNote } from './ClaimCreditNote';
// Define associations
const defineAssociations = () => {
@ -179,7 +182,10 @@ export {
ClaimBudgetTracking,
Dealer,
ActivityType,
DealerClaimHistory
DealerClaimHistory,
ClaimInvoice,
ClaimInvoiceItem,
ClaimCreditNote
};
// Export default sequelize instance

View File

@ -1,6 +1,21 @@
import { Router } from 'express';
import { authenticateToken } from '@middlewares/auth.middleware';
import { requireAdmin } from '@middlewares/authorization.middleware';
import { validateBody, validateParams } from '../middlewares/validate.middleware';
import {
createHolidaySchema,
updateHolidaySchema,
holidayParamsSchema,
calendarParamsSchema,
configKeyParamsSchema,
updateConfigSchema,
assignRoleSchema,
updateRoleSchema,
userIdParamsSchema,
createActivityTypeSchema,
updateActivityTypeSchema,
activityTypeParamsSchema,
} from '../validators/admin.validator';
import {
getAllHolidays,
getHolidayCalendar,
@ -44,7 +59,7 @@ router.get('/holidays', getAllHolidays);
* @params year
* @access Admin
*/
router.get('/holidays/calendar/:year', getHolidayCalendar);
router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), getHolidayCalendar);
/**
* @route POST /api/admin/holidays
@ -52,7 +67,7 @@ router.get('/holidays/calendar/:year', getHolidayCalendar);
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
* @access Admin
*/
router.post('/holidays', createHoliday);
router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
/**
* @route PUT /api/admin/holidays/:holidayId
@ -61,7 +76,7 @@ router.post('/holidays', createHoliday);
* @body Holiday fields to update
* @access Admin
*/
router.put('/holidays/:holidayId', updateHoliday);
router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validateBody(updateHolidaySchema), updateHoliday);
/**
* @route DELETE /api/admin/holidays/:holidayId
@ -69,7 +84,7 @@ router.put('/holidays/:holidayId', updateHoliday);
* @params holidayId
* @access Admin
*/
router.delete('/holidays/:holidayId', deleteHoliday);
router.delete('/holidays/:holidayId', validateParams(holidayParamsSchema), deleteHoliday);
/**
* @route POST /api/admin/holidays/bulk-import
@ -96,7 +111,7 @@ router.get('/configurations', getAllConfigurations);
* @body { configValue }
* @access Admin
*/
router.put('/configurations/:configKey', updateConfiguration);
router.put('/configurations/:configKey', validateParams(configKeyParamsSchema), validateBody(updateConfigSchema), updateConfiguration);
/**
* @route POST /api/admin/configurations/:configKey/reset
@ -104,7 +119,7 @@ router.put('/configurations/:configKey', updateConfiguration);
* @params configKey
* @access Admin
*/
router.post('/configurations/:configKey/reset', resetConfiguration);
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
// ==================== User Role Management Routes (RBAC) ====================
@ -114,7 +129,7 @@ router.post('/configurations/:configKey/reset', resetConfiguration);
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
* @access Admin
*/
router.post('/users/assign-role', assignRoleByEmail);
router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEmail);
/**
* @route PUT /api/admin/users/:userId/role
@ -123,7 +138,7 @@ router.post('/users/assign-role', assignRoleByEmail);
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
* @access Admin
*/
router.put('/users/:userId/role', updateUserRole);
router.put('/users/:userId/role', validateParams(userIdParamsSchema), validateBody(updateRoleSchema), updateUserRole);
/**
* @route GET /api/admin/users/by-role
@ -156,7 +171,7 @@ router.get('/activity-types', getAllActivityTypes);
* @params activityTypeId
* @access Admin
*/
router.get('/activity-types/:activityTypeId', getActivityTypeById);
router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), getActivityTypeById);
/**
* @route POST /api/admin/activity-types
@ -164,7 +179,7 @@ router.get('/activity-types/:activityTypeId', getActivityTypeById);
* @body { title, itemCode?, taxationType?, sapRefNo? }
* @access Admin
*/
router.post('/activity-types', createActivityType);
router.post('/activity-types', validateBody(createActivityTypeSchema), createActivityType);
/**
* @route PUT /api/admin/activity-types/:activityTypeId
@ -173,7 +188,7 @@ router.post('/activity-types', createActivityType);
* @body Activity type fields to update
* @access Admin
*/
router.put('/activity-types/:activityTypeId', updateActivityType);
router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), validateBody(updateActivityTypeSchema), updateActivityType);
/**
* @route DELETE /api/admin/activity-types/:activityTypeId
@ -181,7 +196,6 @@ router.put('/activity-types/:activityTypeId', updateActivityType);
* @params activityTypeId
* @access Admin
*/
router.delete('/activity-types/:activityTypeId', deleteActivityType);
router.delete('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), deleteActivityType);
export default router;

View File

@ -0,0 +1,166 @@
/**
* Antivirus Admin Routes
* Admin endpoints for ClamAV management and audit logs.
*/
import { Router, Request, Response } from 'express';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import {
getToggleStatus,
setToggleStatus,
getToggleHistory,
} from '../services/clamav/clamavToggleManager';
import { pingDaemon } from '../services/clamav/clamavScanWrapper';
import {
readAuditLogs,
getAuditStats,
exportAuditLogsCSV,
logSecurityEvent,
SecurityEventType,
} from '../services/logging/securityEventLogger';
const router = Router();
// All routes require admin authentication
router.use(authenticateToken, requireAdmin);
/**
* GET /api/v1/antivirus/clamav-status
* Get ClamAV toggle status, daemon health, and recent toggle history
*/
router.get(
'/clamav-status',
asyncHandler(async (_req: Request, res: Response) => {
const toggleStatus = getToggleStatus();
const daemonStatus = await pingDaemon();
const recentHistory = getToggleHistory(10);
res.json({
success: true,
toggle: toggleStatus,
daemon: daemonStatus,
recentHistory,
});
})
);
/**
* POST /api/v1/antivirus/clamav-toggle
* Enable or disable ClamAV scanning
* Body: { enabled: boolean, reason: string }
*/
router.post(
'/clamav-toggle',
asyncHandler(async (req: Request, res: Response) => {
const { enabled, reason } = req.body;
if (typeof enabled !== 'boolean') {
res.status(400).json({
success: false,
message: '"enabled" must be a boolean',
});
return;
}
if (!reason || typeof reason !== 'string') {
res.status(400).json({
success: false,
message: '"reason" is required',
});
return;
}
const userId = (req as any).user?.id || (req as any).user?.email || 'unknown';
const result = setToggleStatus(enabled, userId, reason);
// Log the admin action
logSecurityEvent(SecurityEventType.CLAMAV_TOGGLE_CHANGED, {
enabled,
reason,
changedBy: userId,
}, userId);
res.json({
success: true,
message: `ClamAV scanning ${enabled ? 'enabled' : 'disabled'}`,
state: result.state,
});
})
);
/**
* GET /api/v1/antivirus/audit-logs
* Search and paginate security audit logs
* Query params: eventType, severity, category, startDate, endDate, limit, offset
*/
router.get(
'/audit-logs',
asyncHandler(async (req: Request, res: Response) => {
const {
eventType,
severity,
category,
startDate,
endDate,
limit = '50',
offset = '0',
} = req.query;
const result = readAuditLogs({
eventType: eventType as string,
severity: severity as string,
category: category as string,
startDate: startDate as string,
endDate: endDate as string,
limit: parseInt(limit as string, 10),
offset: parseInt(offset as string, 10),
});
res.json({
success: true,
...result,
});
})
);
/**
* GET /api/v1/antivirus/audit-logs/export
* Export filtered audit logs as CSV
*/
router.get(
'/audit-logs/export',
asyncHandler(async (req: Request, res: Response) => {
const { eventType, severity, startDate, endDate } = req.query;
const csv = exportAuditLogsCSV({
eventType: eventType as string,
severity: severity as string,
startDate: startDate as string,
endDate: endDate as string,
});
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename=audit-logs-${new Date().toISOString().split('T')[0]}.csv`);
res.send(csv);
})
);
/**
* GET /api/v1/antivirus/audit-stats
* Get audit log statistics
*/
router.get(
'/audit-stats',
asyncHandler(async (_req: Request, res: Response) => {
const stats = getAuditStats();
res.json({
success: true,
stats,
});
})
);
export default router;

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { ApiTokenController } from '../controllers/apiToken.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
const router = Router();
const apiTokenController = new ApiTokenController();
// All routes require authentication
router.use(authenticateToken);
router.post('/', asyncHandler(apiTokenController.create.bind(apiTokenController)));
router.get('/', asyncHandler(apiTokenController.list.bind(apiTokenController)));
router.delete('/:id', asyncHandler(apiTokenController.revoke.bind(apiTokenController)));
export default router;

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import { conclusionController } from '@controllers/conclusion.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { aiLimiter } from '../middlewares/rateLimiter.middleware';
const router = Router();
@ -12,7 +13,7 @@ router.use(authenticateToken);
* @desc Generate AI-powered conclusion remark
* @access Private (Initiator only)
*/
router.post('/:requestId/generate', (req, res) =>
router.post('/:requestId/generate', aiLimiter, (req, res) =>
conclusionController.generateConclusion(req, res)
);
@ -21,7 +22,7 @@ router.post('/:requestId/generate', (req, res) =>
* @desc Update conclusion remark (edit by initiator)
* @access Private (Initiator only)
*/
router.put('/:requestId', (req, res) =>
router.put('/:requestId', (req, res) =>
conclusionController.updateConclusion(req, res)
);
@ -30,7 +31,7 @@ router.put('/:requestId', (req, res) =>
* @desc Finalize conclusion and close request
* @access Private (Initiator only)
*/
router.post('/:requestId/finalize', (req, res) =>
router.post('/:requestId/finalize', (req, res) =>
conclusionController.finalizeConclusion(req, res)
);
@ -39,7 +40,7 @@ router.post('/:requestId/finalize', (req, res) =>
* @desc Get conclusion for a request
* @access Private
*/
router.get('/:requestId', (req, res) =>
router.get('/:requestId', (req, res) =>
conclusionController.getConclusion(req, res)
);

View File

@ -2,9 +2,14 @@ import { Router, Request, Response } from 'express';
import { getPublicConfig } from '../config/system.config';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { activityTypeService } from '../services/activityType.service';
import { generalApiLimiter } from '../middlewares/rateLimiter.middleware';
import { authenticateToken } from '../middlewares/auth.middleware';
const router = Router();
// All config routes get general rate limiting (public endpoints)
router.use(generalApiLimiter);
/**
* GET /api/v1/config
* Returns public system configuration for frontend
@ -27,6 +32,7 @@ router.get('/',
* No authentication required - public endpoint
*/
router.get('/activity-types',
authenticateToken,
asyncHandler(async (req: Request, res: Response): Promise<void> => {
const activityTypes = await activityTypeService.getAllActivityTypes(true);
res.json({
@ -42,6 +48,4 @@ router.get('/activity-types',
return;
})
);
export default router;

View File

@ -3,6 +3,16 @@ import { DealerClaimController } from '../controllers/dealerClaim.controller';
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { malwareScanMiddleware, malwareScanMultipleMiddleware } from '../middlewares/malwareScan.middleware';
import { sapLimiter, uploadLimiter } from '../middlewares/rateLimiter.middleware';
import { validateBody, validateParams } from '../middlewares/validate.middleware';
import {
requestIdParamsSchema,
updateIOSchema,
updateEInvoiceSchema,
sendCreditNoteSchema,
testSapBlockSchema,
} from '../validators/dealerClaim.validator';
import multer from 'multer';
import path from 'path';
@ -46,62 +56,63 @@ router.post('/', authenticateToken, asyncHandler(dealerClaimController.createCla
* @desc Get claim details
* @access Private
*/
router.get('/:requestId', authenticateToken, asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/proposal
* @desc Submit dealer proposal (Step 1)
* @access Private
*/
router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDocument'), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), malwareScanMiddleware, asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/completion
* @desc Submit completion documents (Step 5)
* @access Private
*/
router.post('/:requestId/completion', authenticateToken, upload.fields([
router.post('/:requestId/completion', authenticateToken, uploadLimiter, validateParams(requestIdParamsSchema), upload.fields([
{ name: 'completionDocuments', maxCount: 10 },
{ name: 'activityPhotos', maxCount: 10 },
{ name: 'invoicesReceipts', maxCount: 10 },
{ name: 'attendanceSheet', maxCount: 1 },
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
/**
* @route GET /api/v1/dealer-claims/:requestId/io/validate
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
* @access Private
*/
router.get('/:requestId/io/validate', authenticateToken, asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
router.get('/:requestId/io/validate', authenticateToken, sapLimiter, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/io
* @desc Block IO amount in SAP and store in database
* @access Private
*/
router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
router.put('/:requestId/io', authenticateToken, sapLimiter, validateParams(requestIdParamsSchema), validateBody(updateIOSchema), asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/e-invoice
* @desc Update e-invoice details (Step 7)
* @access Private
*/
router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/pdf', authenticateToken, asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
router.put('/:requestId/e-invoice', authenticateToken, sapLimiter, validateParams(requestIdParamsSchema), validateBody(updateEInvoiceSchema), asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/credit-note
* @desc Update credit note details (Step 8)
* @access Private
*/
router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), malwareScanMiddleware, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
* @desc Send credit note to dealer and auto-approve Step 8
* @access Private
*/
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
router.post('/:requestId/credit-note/send', authenticateToken, validateParams(requestIdParamsSchema), validateBody(sendCreditNoteSchema), asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/test/sap-block
@ -109,7 +120,6 @@ router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(deal
* @access Private
* @body { ioNumber: string, amount: number, requestNumber?: string }
*/
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
router.post('/test/sap-block', authenticateToken, sapLimiter, validateBody(testSapBlockSchema), asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
export default router;

View File

@ -4,6 +4,7 @@ import path from 'path';
import crypto from 'crypto';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { malwareScanMiddleware } from '../middlewares/malwareScan.middleware';
import { DocumentController } from '../controllers/document.controller';
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
@ -20,8 +21,7 @@ const router = Router();
const controller = new DocumentController();
// multipart/form-data: file, requestId, optional category
router.post('/', authenticateToken, upload.single('file'), asyncHandler(controller.upload.bind(controller)));
// Middleware chain: auth → multer → malware scan → controller
router.post('/', authenticateToken, upload.single('file'), malwareScanMiddleware, asyncHandler(controller.upload.bind(controller)));
export default router;

View File

@ -17,8 +17,18 @@ import dealerClaimRoutes from './dealerClaim.routes';
import templateRoutes from './template.routes';
import dealerRoutes from './dealer.routes';
import dmsWebhookRoutes from './dmsWebhook.routes';
import apiTokenRoutes from './apiToken.routes';
import antivirusRoutes from './antivirus.routes';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import {
authLimiter,
uploadLimiter,
adminLimiter,
aiLimiter,
webhookLimiter,
generalApiLimiter,
} from '../middlewares/rateLimiter.middleware';
const router = Router();
@ -31,28 +41,37 @@ router.get('/health', (_req, res) => {
});
});
// API routes
router.use('/auth', authRoutes);
router.use('/config', configRoutes); // System configuration (public)
router.use('/workflows', workflowRoutes);
router.use('/users', userRoutes);
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
router.use('/documents', documentRoutes);
router.use('/tat', tatRoutes);
router.use('/admin', adminRoutes);
router.use('/debug', authenticateToken, requireAdmin, debugRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/notifications', notificationRoutes);
router.use('/conclusions', conclusionRoutes);
router.use('/ai', aiRoutes);
router.use('/summaries', summaryRoutes);
router.use('/dealer-claims', dealerClaimRoutes);
router.use('/templates', templateRoutes);
router.use('/dealers', dealerRoutes);
router.use('/webhooks/dms', dmsWebhookRoutes);
// ── Auth & Admin (strict limits) ──
router.use('/auth', authLimiter, authRoutes); // 20 req/15min
router.use('/admin', adminLimiter, adminRoutes); // 30 req/15min
router.use('/debug', authenticateToken, requireAdmin, adminLimiter, debugRoutes); // 30 req/15min
// Add other route modules as they are implemented
// router.use('/approvals', approvalRoutes);
// router.use('/participants', participantRoutes);
// ── File uploads (moderate limits) ──
router.use('/documents', uploadLimiter, documentRoutes); // 50 req/15min
router.use('/antivirus', uploadLimiter, antivirusRoutes); // 50 req/15min
// ── AI (resource-intensive) ──
router.use('/ai', aiLimiter, aiRoutes); // 20 req/15min
// ── External webhooks (burst-friendly) ──
router.use('/webhooks/dms', webhookLimiter, dmsWebhookRoutes); // 100 req/15min
// ── Dealer claims (SAP/PWC rate limiting at individual route level in dealerClaim.routes.ts) ──
router.use('/dealer-claims', generalApiLimiter, dealerClaimRoutes); // 200 req/15min (SAP routes have additional stricter limits)
// ── Standard API routes (general limits) ──
router.use('/config', configRoutes); // Public config — no limiter
router.use('/workflows', generalApiLimiter, workflowRoutes); // 200 req/15min
router.use('/users', generalApiLimiter, userRoutes); // 200 req/15min
router.use('/user/preferences', generalApiLimiter, userPreferenceRoutes); // 200 req/15min
router.use('/tat', generalApiLimiter, tatRoutes); // 200 req/15min
router.use('/dashboard', generalApiLimiter, dashboardRoutes); // 200 req/15min
router.use('/notifications', generalApiLimiter, notificationRoutes); // 200 req/15min
router.use('/conclusions', generalApiLimiter, conclusionRoutes); // 200 req/15min
router.use('/summaries', generalApiLimiter, summaryRoutes); // 200 req/15min
router.use('/templates', generalApiLimiter, templateRoutes); // 200 req/15min
router.use('/dealers', generalApiLimiter, dealerRoutes); // 200 req/15min
router.use('/api-tokens', authLimiter, apiTokenRoutes); // 20 req/15min (sensitive — same as auth)
export default router;

View File

@ -11,6 +11,8 @@ import { requireParticipantTypes } from '../middlewares/authorization.middleware
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
import { malwareScanMultipleMiddleware } from '../middlewares/malwareScan.middleware';
import { uploadLimiter } from '../middlewares/rateLimiter.middleware';
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
import { notificationService } from '../services/notification.service';
import { Activity } from '@models/Activity';
@ -101,7 +103,9 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
router.post('/multipart',
authenticateToken,
uploadLimiter,
upload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController))
);
@ -127,8 +131,10 @@ router.put('/:id',
// Multipart update (payload + files[]) for draft updates
router.put('/:id/multipart',
authenticateToken,
uploadLimiter,
validateParams(workflowParamsSchema),
upload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workflowController.updateWorkflowMultipart.bind(workflowController))
);
@ -216,8 +222,10 @@ router.get('/:id/work-notes',
const noteUpload = upload; // reuse same memory storage/limits
router.post('/:id/work-notes',
authenticateToken,
uploadLimiter,
validateParams(workflowParamsSchema),
noteUpload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workNoteController.create.bind(workNoteController))
);

View File

@ -159,6 +159,8 @@ async function runMigrations(): Promise<void> {
const m44 = require('../migrations/20260123-fix-template-id-schema');
const m45 = require('../migrations/20260209-add-gst-and-pwc-fields');
const m46 = require('../migrations/20260210-add-raw-pwc-responses');
const m47 = require('../migrations/20260216-create-api-tokens');
const m48 = require('../migrations/20260216-add-qty-hsn-to-expenses'); // Added new migration
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -210,6 +212,8 @@ async function runMigrations(): Promise<void> {
{ name: '20260123-fix-template-id-schema', module: m44 },
{ name: '20260209-add-gst-and-pwc-fields', module: m45 },
{ name: '20260210-add-raw-pwc-responses', module: m46 },
{ name: '20260216-create-api-tokens', module: m47 },
{ name: '20260216-add-qty-hsn-to-expenses', module: m48 }, // Added to array
];
// Dynamically import sequelize after secrets are loaded

View File

@ -48,6 +48,9 @@ import * as m42 from '../migrations/20250125-create-activity-types';
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
import * as m44 from '../migrations/20260123-fix-template-id-schema';
import * as m45 from '../migrations/20260209-add-gst-and-pwc-fields';
import * as m46 from '../migrations/20260216-add-qty-hsn-to-expenses';
import * as m47 from '../migrations/20260217-add-is-service-to-expenses';
import * as m48 from '../migrations/20260217-create-claim-invoice-items';
interface Migration {
name: string;
@ -57,59 +60,10 @@ interface Migration {
// Define all migrations in order
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
const migrations: Migration[] = [
// 1. FIRST: Create base tables with no dependencies
{ name: '2025103000-create-users', module: m0 }, // ← MUST BE FIRST
// 2. Tables that depend on users
{ name: '2025103001-create-workflow-requests', module: m1 },
{ name: '2025103002-create-approval-levels', module: m2 },
{ name: '2025103003-create-participants', module: m3 },
{ name: '2025103004-create-documents', module: m4 },
{ name: '20251031_01_create_subscriptions', module: m5 },
{ name: '20251031_02_create_activities', module: m6 },
{ name: '20251031_03_create_work_notes', module: m7 },
{ name: '20251031_04_create_work_note_attachments', module: m8 },
// 3. Table modifications and additional features
{ name: '20251104-add-tat-alert-fields', module: m9 },
{ name: '20251104-create-tat-alerts', module: m10 },
{ name: '20251104-create-kpi-views', module: m11 },
{ name: '20251104-create-holidays', module: m12 },
{ name: '20251104-create-admin-config', module: m13 },
{ name: '20251105-add-skip-fields-to-approval-levels', module: m14 },
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
{ name: '20251121-add-ai-model-configs', module: m19 },
{ name: '20250122-create-request-summaries', module: m20 },
{ name: '20250122-create-shared-summaries', module: m21 },
{ name: '20250123-update-request-number-format', module: m22 },
{ name: '20250126-add-paused-to-enum', module: m23 },
{ name: '20250126-add-paused-to-workflow-status-enum', module: m24 },
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
// Base branch migrations (m28-m29)
{ name: '20250130-migrate-to-vertex-ai', module: m28 },
{ name: '20251203-add-user-notification-preferences', module: m29 },
// Dealer claim branch migrations (m30-m39)
{ name: '20251210-add-workflow-type-support', module: m30 },
{ name: '20251210-enhance-workflow-templates', module: m31 },
{ name: '20251210-add-template-id-foreign-key', module: m32 },
{ name: '20251210-create-dealer-claim-tables', module: m33 },
{ name: '20251210-create-proposal-cost-items-table', module: m34 },
{ name: '20251211-create-internal-orders-table', module: m35 },
{ name: '20251211-create-claim-budget-tracking-table', module: m36 },
{ name: '20251213-drop-claim-details-invoice-columns', module: m37 },
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
{ name: '20260123-fix-template-id-schema', module: m44 },
{ name: '20260209-add-gst-and-pwc-fields', module: m45 }
// ... existing migrations ...
{ name: '20260216-add-qty-hsn-to-expenses', module: m46 },
{ name: '20260217-add-is-service-to-expenses', module: m47 },
{ name: '20260217-create-claim-invoice-items', module: m48 }
];
/**

View File

@ -0,0 +1,74 @@
import { sequelize } from '../config/database';
import { User } from '../models/User';
import { ApiTokenService } from '../services/apiToken.service';
async function testApiTokens() {
try {
console.log('🔌 Connecting to database...');
await sequelize.authenticate();
console.log('✅ Database connected');
const apiTokenService = new ApiTokenService();
// 1. Find an admin user
const adminUser = await User.findOne({ where: { role: 'ADMIN' } });
if (!adminUser) {
console.error('❌ No admin user found. Please seed the database first.');
process.exit(1);
}
console.log(`👤 Found Admin User: ${adminUser.email}`);
// 2. Create a Token
console.log('🔑 Creating API Token...');
const tokenName = 'Test Token ' + Date.now();
const { token, apiToken } = await apiTokenService.createToken(adminUser.userId, tokenName, 30);
console.log(`✅ Token Created: ${token}`);
console.log(` ID: ${apiToken.id}`);
console.log(` Prefix: ${apiToken.prefix}`);
// 3. Verify Token
console.log('🔍 Verifying Token...');
const verifiedUser = await apiTokenService.verifyToken(token);
if (verifiedUser && verifiedUser.userId === adminUser.userId) {
console.log('✅ Token Verification Successful');
} else {
console.error('❌ Token Verification Failed');
}
// 4. List Tokens
console.log('📋 Listing Tokens...');
const tokens = await apiTokenService.listTokens(adminUser.userId);
console.log(`✅ Found ${tokens.length} tokens`);
const createdToken = tokens.find(t => t.id === apiToken.id);
if (createdToken) {
console.log('✅ Created token found in list');
} else {
console.error('❌ Created token NOT found in list');
}
// 5. Revoke Token
console.log('🚫 Revoking Token...');
const revoked = await apiTokenService.revokeToken(adminUser.userId, apiToken.id);
if (revoked) {
console.log('✅ Token Revoked Successfully');
} else {
console.error('❌ Token Revocation Failed');
}
// 6. Verify Revocation
console.log('🔍 Verifying Revoked Token...');
const revokedUser = await apiTokenService.verifyToken(token);
if (!revokedUser) {
console.log('✅ Revoked Token Verification Successful (Access Denied)');
} else {
console.error('❌ Revoked Token Verification Failed (Access Granted)');
}
} catch (error) {
console.error('❌ Test Failed:', error);
} finally {
await sequelize.close();
}
}
testApiTokens();

View File

@ -8,19 +8,19 @@ import { ActivityType } from '@models/ActivityType';
* These will be seeded into the database with default item_code values (1-13)
*/
const DEFAULT_ACTIVITY_TYPES = [
{ title: 'Riders Mania Claims', itemCode: '1' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2' },
{ title: 'Media Bike Service', itemCode: '3' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10' },
{ title: 'Legal Claims Reimbursement', itemCode: '11' },
{ title: 'Service Camp Claims', itemCode: '12' },
{ title: 'Corporate Claims Institutional Sales PDI', itemCode: '13' }
{ title: 'Riders Mania Claims', itemCode: '1', taxationType: 'Non GST', sapRefNo: 'ZRDM' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2', taxationType: 'Non GST', sapRefNo: 'ZMBV' },
{ title: 'Media Bike Service', itemCode: '3', taxationType: 'GST', sapRefNo: 'ZMBS' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4', taxationType: 'GST', sapRefNo: 'ZAML' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5', taxationType: 'Non GST', sapRefNo: 'ZACS' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6', taxationType: 'GST', sapRefNo: 'ZPPE' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7', taxationType: 'Non GST', sapRefNo: 'ZFMB' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8', taxationType: 'Non GST', sapRefNo: 'ZMBG' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9', taxationType: 'GST', sapRefNo: 'ZLUM' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10', taxationType: 'GST', sapRefNo: 'ZMRC' },
{ title: 'Legal Claims Reimbursement', itemCode: '11', taxationType: 'Non GST', sapRefNo: 'ZLCR' },
{ title: 'Service Camp Claims', itemCode: '12', taxationType: 'Non GST', sapRefNo: 'ZSCC' },
{ title: 'Corporate Claims Institutional Sales PDI', itemCode: '13', taxationType: 'Non GST', sapRefNo: 'ZCCN' }
];
/**
@ -40,7 +40,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
);
const exists = tableExists && tableExists.length > 0 && (tableExists[0] as any).exists;
if (!exists) {
logger.warn('[ActivityType Seed] ⚠️ activity_types table does not exist. Please run migrations first (npm run migrate). Skipping seed.');
return;
@ -71,7 +71,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
let skippedCount = 0;
for (const activityType of DEFAULT_ACTIVITY_TYPES) {
const { title, itemCode } = activityType;
const { title, itemCode, taxationType, sapRefNo } = activityType;
try {
// Check if activity type already exists (active or inactive)
const existing = await ActivityType.findOne({
@ -79,45 +79,41 @@ export async function seedDefaultActivityTypes(): Promise<void> {
});
if (existing) {
// If exists but inactive, reactivate it
// Identify fields to update
const updates: any = {};
if (!existing.itemCode && itemCode) updates.itemCode = itemCode;
if (!existing.taxationType && taxationType) updates.taxationType = taxationType;
if (!existing.sapRefNo && sapRefNo) updates.sapRefNo = sapRefNo;
if (!existing.isActive) {
// Update item_code if it's null (preserve if user has already set it)
const updateData: any = {
isActive: true,
updatedBy: systemUserId
};
// Only set item_code if it's currently null (don't overwrite user edits)
if (!existing.itemCode) {
updateData.itemCode = itemCode;
}
await existing.update(updateData);
updates.isActive = true;
updates.updatedBy = systemUserId;
await existing.update(updates);
updatedCount++;
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title}${!existing.itemCode ? ` (set item_code: ${itemCode})` : ''}`);
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
} else {
// Already exists and active
// Update item_code if it's null (preserve if user has already set it)
if (!existing.itemCode) {
await existing.update({
itemCode: itemCode,
updatedBy: systemUserId
} as any);
logger.debug(`[ActivityType Seed] Updated item_code for existing activity type: ${title} (${itemCode})`);
if (Object.keys(updates).length > 0) {
updates.updatedBy = systemUserId;
await existing.update(updates);
logger.debug(`[ActivityType Seed] Updated fields for existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
} else {
skippedCount++;
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
}
skippedCount++;
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
}
} else {
// Create new activity type with default item_code
// Create new activity type with default fields
await ActivityType.create({
title,
itemCode: itemCode,
taxationType: null,
sapRefNo: null,
taxationType: taxationType,
sapRefNo: sapRefNo,
isActive: true,
createdBy: systemUserId
} as any);
createdCount++;
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode})`);
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode}, sap_ref: ${sapRefNo})`);
}
} catch (error: any) {
// Log error but continue with other activity types
@ -132,7 +128,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
{ type: QueryTypes.SELECT }
);
const totalCount = result && (result[0] as any).count ? (result[0] as any).count : 0;
logger.info(`[ActivityType Seed] ✅ Activity type seeding complete. Created: ${createdCount}, Reactivated: ${updatedCount}, Skipped: ${skippedCount}, Total active: ${totalCount}`);
} catch (error: any) {
logger.error('[ActivityType Seed] ❌ Error seeding activity types:', {

View File

@ -0,0 +1,119 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { ApiToken } from '../models/ApiToken';
import { User } from '../models/User';
import logger from '../utils/logger';
export class ApiTokenService {
private static readonly TOKEN_PREFIX = 're_';
private static readonly TOKEN_LENGTH = 32;
/**
* Generate a new API token for a user
*/
async createToken(userId: string, name: string, expiresInDays?: number): Promise<{ token: string; apiToken: ApiToken }> {
try {
// 1. Generate secure random token
const randomBytes = crypto.randomBytes(ApiTokenService.TOKEN_LENGTH);
const randomString = randomBytes.toString('hex');
const token = `${ApiTokenService.TOKEN_PREFIX}${randomString}`;
// 2. Hash the token
const tokenHash = await bcrypt.hash(token, 10);
// 3. Calculate expiry
let expiresAt: Date | undefined;
if (expiresInDays) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
}
// 4. Save to DB
const apiToken = await ApiToken.create({
userId,
name,
prefix: token.substring(0, 8), // Store first 8 chars (prefix + 5 chars) for lookup
tokenHash,
expiresAt,
isActive: true,
});
logger.info(`API Token created for user ${userId}: ${name}`);
// Return raw token ONLY here
return { token, apiToken };
} catch (error) {
logger.error('Error creating API token:', error);
throw new Error('Failed to create API token');
}
}
/**
* List all active tokens for a user
*/
async listTokens(userId: string): Promise<ApiToken[]> {
return await ApiToken.findAll({
where: { userId, isActive: true },
order: [['createdAt', 'DESC']],
attributes: { exclude: ['tokenHash'] } // Never return hash
});
}
/**
* Revoke (delete) a token
*/
async revokeToken(userId: string, tokenId: string): Promise<boolean> {
const result = await ApiToken.destroy({
where: { id: tokenId, userId }
});
return result > 0;
}
/**
* Verify an API token and return the associated user
*/
async verifyToken(rawToken: string): Promise<User | null> {
try {
if (!rawToken.startsWith(ApiTokenService.TOKEN_PREFIX)) {
return null;
}
// 1. Find potential tokens by prefix (optimization)
const prefix = rawToken.substring(0, 8);
const tokens = await ApiToken.findAll({
where: {
prefix,
isActive: true
},
include: [{
model: User,
as: 'user',
where: { isActive: true } // Ensure user is also active
}]
});
// 2. check hash for each candidate
for (const tokenRecord of tokens) {
const isValid = await bcrypt.compare(rawToken, tokenRecord.tokenHash);
if (isValid) {
// 3. Check expiry
if (tokenRecord.expiresAt && new Date() > tokenRecord.expiresAt) {
logger.warn(`Expired API token used: ${tokenRecord.id}`);
return null;
}
// 4. Update last used
await tokenRecord.update({ lastUsedAt: new Date() });
// Return the user (casted as any because include types can be tricky)
return (tokenRecord as any).user;
}
}
return null;
} catch (error) {
logger.error('Error verifying API token:', error);
return null;
}
}
}

View File

@ -0,0 +1,227 @@
/**
* ClamAV Scan Wrapper
* Low-level ClamAV communication using TCP mode via clamscan npm package.
* Scans files, extracts virus signatures, and pings daemon health.
*
* IMPORTANT: Do not cache the scanner instance each scan creates a fresh
* connection to avoid stale TCP socket issues.
*/
import net from 'net';
import { getToggleStatus } from './clamavToggleManager';
import { logSecurityEvent, SecurityEventType } from '../logging/securityEventLogger';
// ── Types ──
export interface ClamScanResult {
isInfected: boolean;
virusNames: string[];
scanned: boolean;
skipped: boolean;
scanDuration: number;
rawOutput: string;
error?: string;
}
export interface ClamDaemonStatus {
available: boolean;
host: string;
port: number;
responseTime: number;
error?: string;
}
// ── Configuration ──
const CLAMD_HOST = process.env.CLAMD_HOST || 'localhost';
const CLAMD_PORT = parseInt(process.env.CLAMD_PORT || '3310', 10);
const CLAMD_TIMEOUT = parseInt(process.env.CLAMD_TIMEOUT_MS || '30000', 10);
// ── Scanner Factory (fresh instance per scan) ──
async function createScanner(): Promise<any> {
const NodeClamscan = (await import('clamscan')).default;
const clamscan = await new NodeClamscan().init({
removeInfected: false,
debugMode: false,
scanRecursively: false,
clamdscan: {
host: CLAMD_HOST,
port: CLAMD_PORT,
timeout: CLAMD_TIMEOUT,
localFallback: false,
multiscan: false,
active: true,
},
preference: 'clamdscan',
});
return clamscan;
}
// ── Core Functions ──
/**
* Scan a file for malware using ClamAV daemon.
* Creates a fresh scanner connection for each scan to avoid stale sockets.
*/
export async function scanFile(filePath: string): Promise<ClamScanResult> {
const startTime = Date.now();
// Check if scanning is enabled via admin toggle
const toggleStatus = getToggleStatus();
if (!toggleStatus.enabled) {
console.log('[ClamAV] Scanning disabled via toggle — skipping scan');
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: true,
scanDuration: 0,
rawOutput: 'Scanning disabled by administrator',
};
}
// Check if ClamAV is enabled via env
if (process.env.ENABLE_CLAMAV === 'false') {
console.log('[ClamAV] Disabled via ENABLE_CLAMAV=false — skipping scan');
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: true,
scanDuration: 0,
rawOutput: 'ClamAV disabled via ENABLE_CLAMAV env',
};
}
try {
console.log(`[ClamAV] Scanning file: ${filePath}`);
const scanner = await createScanner();
const scanResult = await scanner.isInfected(filePath);
const scanDuration = Date.now() - startTime;
console.log(`[ClamAV] Raw scan result:`, JSON.stringify({
isInfected: scanResult.isInfected,
file: scanResult.file,
viruses: scanResult.viruses,
}));
// IMPORTANT: clamscan can return null for isInfected when the daemon
// fails to respond properly. Treat null as a scan failure, not clean.
if (scanResult.isInfected === null || scanResult.isInfected === undefined) {
console.error('[ClamAV] Scan returned null/undefined — treating as scan failure (fail-secure)');
logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, {
filePath,
error: 'ClamAV returned null result — possible daemon connection issue',
scanDuration,
});
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: false,
scanDuration,
rawOutput: 'ClamAV returned null/undefined isInfected',
error: 'Scan result was inconclusive — ClamAV daemon may be unreachable',
};
}
const result: ClamScanResult = {
isInfected: scanResult.isInfected === true,
virusNames: scanResult.viruses || [],
scanned: true,
skipped: false,
scanDuration,
rawOutput: `File: ${scanResult.file}, Infected: ${scanResult.isInfected}, Viruses: ${(scanResult.viruses || []).join(', ')}`,
};
// Log the scan event
if (result.isInfected) {
console.log(`[ClamAV] ⛔ MALWARE DETECTED in ${filePath}: ${result.virusNames.join(', ')}`);
logSecurityEvent(SecurityEventType.MALWARE_DETECTED, {
filePath,
virusNames: result.virusNames,
scanDuration,
});
} else {
console.log(`[ClamAV] ✅ File clean: ${filePath} (${scanDuration}ms)`);
logSecurityEvent(SecurityEventType.MALWARE_SCAN_CLEAN, {
filePath,
scanDuration,
});
}
return result;
} catch (error: any) {
const scanDuration = Date.now() - startTime;
console.error(`[ClamAV] ❌ Scan error for ${filePath}:`, error.message);
logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, {
filePath,
error: error.message,
scanDuration,
});
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: false,
scanDuration,
rawOutput: '',
error: error.message,
};
}
}
/**
* Ping ClamAV daemon via TCP socket to check availability
*/
export async function pingDaemon(): Promise<ClamDaemonStatus> {
const startTime = Date.now();
return new Promise((resolve) => {
const socket = new net.Socket();
const timeout = 5000;
socket.setTimeout(timeout);
socket.connect(CLAMD_PORT, CLAMD_HOST, () => {
// Send PING command
socket.write('zPING\0');
});
socket.on('data', (data) => {
const response = data.toString().trim();
socket.destroy();
resolve({
available: response === 'PONG',
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
});
});
socket.on('error', (error: any) => {
socket.destroy();
resolve({
available: false,
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
error: error.message,
});
});
socket.on('timeout', () => {
socket.destroy();
resolve({
available: false,
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
error: 'Connection timed out',
});
});
});
}

View File

@ -0,0 +1,162 @@
/**
* ClamAV Toggle Manager
* Admin enable/disable control for antivirus scanning with full audit trail.
* Toggle state is persisted in logs/clamav-toggle.json.
*/
import fs from 'fs';
import path from 'path';
// ── Types ──
interface ToggleHistoryEntry {
timestamp: string;
enabled: boolean;
changedBy: string;
reason: string;
}
interface ToggleState {
enabled: boolean;
lastChanged: string;
changedBy: string;
reason: string;
history: ToggleHistoryEntry[];
}
// ── Configuration ──
const LOGS_DIR = path.resolve(process.cwd(), 'logs');
const TOGGLE_FILE = path.join(LOGS_DIR, 'clamav-toggle.json');
const MAX_HISTORY_ENTRIES = 100;
// ── In-memory cache ──
let cachedState: ToggleState | null = null;
// ── Functions ──
/**
* Initialize the toggle file with default state (enabled)
*/
export function initializeToggleFile(): void {
try {
// Ensure logs directory exists
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
// If toggle file exists, load it into cache
if (fs.existsSync(TOGGLE_FILE)) {
const data = fs.readFileSync(TOGGLE_FILE, 'utf-8');
cachedState = JSON.parse(data);
console.log(`[ClamAV Toggle] Loaded state: enabled=${cachedState?.enabled}`);
return;
}
// Create default toggle state
const defaultState: ToggleState = {
enabled: true,
lastChanged: new Date().toISOString(),
changedBy: 'system',
reason: 'Initial setup',
history: [
{
timestamp: new Date().toISOString(),
enabled: true,
changedBy: 'system',
reason: 'Initial setup',
},
],
};
fs.writeFileSync(TOGGLE_FILE, JSON.stringify(defaultState, null, 2));
cachedState = defaultState;
console.log('[ClamAV Toggle] Initialized with default state (enabled)');
} catch (error) {
console.error('[ClamAV Toggle] Failed to initialize:', error);
// Default to enabled if we can't read the file
cachedState = {
enabled: true,
lastChanged: new Date().toISOString(),
changedBy: 'system',
reason: 'Fallback default',
history: [],
};
}
}
/**
* Get current toggle status
*/
export function getToggleStatus(): { enabled: boolean; lastChanged: string; changedBy: string; reason: string } {
if (!cachedState) {
initializeToggleFile();
}
return {
enabled: cachedState?.enabled ?? true,
lastChanged: cachedState?.lastChanged ?? new Date().toISOString(),
changedBy: cachedState?.changedBy ?? 'system',
reason: cachedState?.reason ?? 'Default',
};
}
/**
* Set toggle status with audit trail
*/
export function setToggleStatus(
enabled: boolean,
userId: string,
reason: string
): { success: boolean; state: ToggleState } {
if (!cachedState) {
initializeToggleFile();
}
const now = new Date().toISOString();
// Add to history
const historyEntry: ToggleHistoryEntry = {
timestamp: now,
enabled,
changedBy: userId,
reason,
};
cachedState = {
enabled,
lastChanged: now,
changedBy: userId,
reason,
history: [
historyEntry,
...(cachedState?.history || []),
].slice(0, MAX_HISTORY_ENTRIES),
};
// Persist to disk
try {
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
fs.writeFileSync(TOGGLE_FILE, JSON.stringify(cachedState, null, 2));
} catch (error) {
console.error('[ClamAV Toggle] Failed to persist toggle state:', error);
}
console.log(`[ClamAV Toggle] Changed to enabled=${enabled} by ${userId}: ${reason}`);
return { success: true, state: cachedState };
}
/**
* Get toggle change history
*/
export function getToggleHistory(limit: number = 50): ToggleHistoryEntry[] {
if (!cachedState) {
initializeToggleFile();
}
return (cachedState?.history || []).slice(0, limit);
}

View File

@ -1,3 +1,6 @@
import { Op } from 'sequelize';
import { sequelize } from '../config/database';
import logger from '../utils/logger';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { DealerProposalDetails } from '../models/DealerProposalDetails';
@ -12,19 +15,22 @@ import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant';
import { User } from '../models/User';
import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory';
import { ActivityType } from '../models/ActivityType';
import { Document } from '../models/Document';
import { Dealer } from '../models/Dealer';
import { WorkflowService } from './workflow.service';
import { DealerClaimApprovalService } from './dealerClaimApproval.service';
import { generateRequestNumber } from '../utils/helpers';
import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types';
import { sapIntegrationService } from './sapIntegration.service';
import { pwcIntegrationService } from './pwcIntegration.service';
import { findDealerLocally } from './dealer.service';
import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { UserService } from './user.service';
import { dmsIntegrationService } from './dmsIntegration.service';
import { findDealerLocally } from './dealer.service';
import logger from '../utils/logger';
// findDealerLocally removed (duplicate)
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
@ -171,7 +177,7 @@ export class DealerClaimService {
levelNumber: a.level,
levelName: levelName,
approverId: approverUserId || '', // Fallback to empty string if still not resolved
approverEmail: `system@${appDomain}`,
approverEmail: a.email,
approverName: a.name || a.email,
tatHours: tatHours,
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
@ -1077,6 +1083,59 @@ export class DealerClaimService {
let serializedClaimDetails = null;
if (claimDetails) {
serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails;
// Fetch default GST rate and taxation type from ActivityType table
try {
const activityTypeTitle = (claimDetails.activityType || '').trim();
logger.info(`[DealerClaimService] Resolving taxationType for activity: "${activityTypeTitle}"`);
let activity = await ActivityType.findOne({
where: { title: activityTypeTitle }
});
// Fallback 1: Try normalized title (handling en-dash vs hyphen)
if (!activity && activityTypeTitle) {
const normalizedTitle = activityTypeTitle.replace(//g, '-');
if (normalizedTitle !== activityTypeTitle) {
activity = await ActivityType.findOne({
where: { title: normalizedTitle }
});
}
}
// Fallback 2: Handle cases where activity is found but taxationType is missing, or activity not found
if (activity && activity.taxationType) {
serializedClaimDetails.defaultGstRate = Number(activity.gstRate) || 18;
serializedClaimDetails.taxationType = activity.taxationType;
logger.info(`[DealerClaimService] Resolved from ActivityType record: ${activity.taxationType}`);
} else {
// Infer from title if record is missing or incomplete
const isNonGst = activityTypeTitle.toLowerCase().includes('non');
serializedClaimDetails.taxationType = isNonGst ? 'Non GST' : 'GST';
serializedClaimDetails.defaultGstRate = isNonGst ? 0 : (activity ? (Number(activity.gstRate) || 18) : 18);
logger.info(`[DealerClaimService] Inferred taxationType from title: ${serializedClaimDetails.taxationType} (Activity record ${activity ? 'found but missing taxationType' : 'not found'})`);
}
} catch (error) {
logger.warn(`[DealerClaimService] Error fetching activity type for ${claimDetails.activityType}:`, error);
serializedClaimDetails.defaultGstRate = 18;
serializedClaimDetails.taxationType = 'GST'; // Safe default
}
// Fetch dealer GSTIN from dealers table
try {
const dealer = await Dealer.findOne({
where: { dlrcode: claimDetails.dealerCode }
});
if (dealer) {
serializedClaimDetails.dealerGstin = dealer.gst || null;
// Also add for backward compatibility if needed
serializedClaimDetails.dealerGSTIN = dealer.gst || null;
}
} catch (dealerError) {
logger.warn(`[DealerClaimService] Error fetching dealer GSTIN for ${claimDetails.dealerCode}:`, dealerError);
}
}
// Transform proposal details to include cost items as array
@ -1090,7 +1149,15 @@ export class DealerClaimService {
// Use cost items from separate table
costBreakup = proposalData.costItems.map((item: any) => ({
description: item.itemDescription || item.description,
amount: Number(item.amount) || 0
amount: Number(item.amount) || 0,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstAmt: Number(item.igstAmt) || 0,
totalAmt: Number(item.totalAmt) || 0
}));
}
// Note: costBreakup JSONB field has been removed - only using separate table now
@ -1156,7 +1223,14 @@ export class DealerClaimService {
const expenseData = expense.toJSON ? expense.toJSON() : expense;
return {
description: expenseData.description || '',
amount: Number(expenseData.amount) || 0
amount: Number(expenseData.amount) || 0,
gstRate: Number(expenseData.gstRate) || 0,
gstAmt: Number(expenseData.gstAmt) || 0,
cgstAmt: Number(expenseData.cgstAmt) || 0,
sgstAmt: Number(expenseData.sgstAmt) || 0,
igstAmt: Number(expenseData.igstAmt) || 0,
totalAmt: Number(expenseData.totalAmt) || 0,
expenseDate: expenseData.expenseDate
};
});
@ -1258,38 +1332,38 @@ export class DealerClaimService {
throw new Error('Failed to get proposal ID after saving proposal details');
}
// Save cost items to separate table (preferred approach)
if (proposalData.costBreakup && proposalData.costBreakup.length > 0) {
// Delete existing cost items for this proposal (in case of update)
await DealerProposalCostItem.destroy({
where: { proposalId }
});
// Clear existing cost items for this proposal (in case of update)
await DealerProposalCostItem.destroy({
where: { proposalId }
});
// Insert new cost items
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
proposalId,
requestId,
itemDescription: item.description || item.itemDescription || '',
amount: Number(item.amount) || 0,
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
itemOrder: index
}));
// Insert new cost items
const costItems = proposalData.costBreakup.map((item: any, index: number) => ({
proposalId,
requestId,
itemDescription: item.description || item.itemDescription || '',
amount: Number(item.amount) || 0,
quantity: Number(item.quantity) || 1,
hsnCode: item.hsnCode || '',
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
isService: !!item.isService,
itemOrder: index
}));
await DealerProposalCostItem.bulkCreate(costItems);
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
}
await DealerProposalCostItem.bulkCreate(costItems);
logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`);
// Update budget tracking with proposal estimate
await ClaimBudgetTracking.upsert({
@ -1414,30 +1488,79 @@ export class DealerClaimService {
});
// Persist individual closed expenses to dealer_completion_expenses
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
const buyerStateCode = "33";
let dealerStateCode = "33";
if (dealer?.gstin && dealer.gstin.length >= 2 && !isNaN(Number(dealer.gstin.substring(0, 2)))) {
dealerStateCode = dealer.gstin.substring(0, 2);
} else if (dealer?.state) {
if (dealer.state.toLowerCase().includes('tamil nadu')) dealerStateCode = "33";
else dealerStateCode = "00";
}
const isIGST = dealerStateCode !== buyerStateCode;
const completionId = (completionDetails as any)?.completionId;
if (completionData.closedExpenses && completionData.closedExpenses.length > 0) {
// Determine taxation type for fallback logic
let isNonGst = false;
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
const taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
}
// Clear existing expenses for this request to avoid duplicates
await DealerCompletionExpense.destroy({ where: { requestId } });
const expenseRows = completionData.closedExpenses.map((item: any) => ({
requestId,
completionId,
description: item.description,
amount: Number(item.amount) || 0,
gstRate: Number(item.gstRate) || 0,
gstAmt: Number(item.gstAmt) || 0,
cgstRate: Number(item.cgstRate) || 0,
cgstAmt: Number(item.cgstAmt) || 0,
sgstRate: Number(item.sgstRate) || 0,
sgstAmt: Number(item.sgstAmt) || 0,
igstRate: Number(item.igstRate) || 0,
igstAmt: Number(item.igstAmt) || 0,
utgstRate: Number(item.utgstRate) || 0,
utgstAmt: Number(item.utgstAmt) || 0,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || Number(item.amount) || 0,
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
}));
const expenseRows = completionData.closedExpenses.map((item: any) => {
const amount = Number(item.amount) || 0;
const quantity = Number(item.quantity) || 1;
const baseTotal = amount * quantity;
// Use provided tax details or calculate if missing/zero
let gstRate = Number(item.gstRate);
if (isNaN(gstRate) || gstRate === 0) {
// Fallback to activity GST rate ONLY for GST claims
gstRate = isNonGst ? 0 : 18;
}
const hasUtgst = (Number(item.utgstRate) > 0 || Number(item.utgstAmt) > 0);
const finalIgstRate = isIGST ? (Number(item.igstRate) || gstRate) : 0;
const finalCgstRate = !isIGST ? (Number(item.cgstRate) || gstRate / 2) : 0;
const finalSgstRate = (!isIGST && !hasUtgst) ? (Number(item.sgstRate) || gstRate / 2) : 0;
const finalUtgstRate = (!isIGST && hasUtgst) ? (Number(item.utgstRate) || gstRate / 2) : 0;
const finalIgstAmt = isIGST ? (Number(item.igstAmt) || (baseTotal * finalIgstRate) / 100) : 0;
const finalCgstAmt = !isIGST ? (Number(item.cgstAmt) || (baseTotal * finalCgstRate) / 100) : 0;
const finalSgstAmt = (!isIGST && !hasUtgst) ? (Number(item.sgstAmt) || (baseTotal * finalSgstRate) / 100) : 0;
const finalUtgstAmt = (!isIGST && hasUtgst) ? (Number(item.utgstAmt) || (baseTotal * finalUtgstRate) / 100) : 0;
const totalTaxAmt = finalIgstAmt + finalCgstAmt + finalSgstAmt + finalUtgstAmt;
return {
requestId,
completionId,
description: item.description,
amount,
quantity,
hsnCode: item.hsnCode || '',
gstRate,
gstAmt: totalTaxAmt,
cgstRate: finalCgstRate,
cgstAmt: finalCgstAmt,
sgstRate: finalSgstRate,
sgstAmt: finalSgstAmt,
igstRate: finalIgstRate,
igstAmt: finalIgstAmt,
utgstRate: finalUtgstRate,
utgstAmt: finalUtgstAmt,
cessRate: Number(item.cessRate) || 0,
cessAmt: Number(item.cessAmt) || 0,
totalAmt: Number(item.totalAmt) || (baseTotal + totalTaxAmt),
isService: !!item.isService,
expenseDate: item.date instanceof Date ? item.date : (item.date ? new Date(item.date) : (completionData.activityCompletionDate || new Date())),
};
});
await DealerCompletionExpense.bulkCreate(expenseRows);
}
@ -1914,7 +2037,15 @@ export class DealerClaimService {
|| budgetTracking?.initialEstimatedBudget
|| 0;
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount);
// Generate custom invoice number based on specific format: INDC + DealerCode + AB + Sequence
// Format: INDC[DealerCode]AB[Sequence] (e.g., INDC004597AB0001)
logger.info(`[DealerClaimService] Generating custom invoice number for dealer: ${claimDetails.dealerCode}`);
const customInvoiceNumber = await this.generateCustomInvoiceNumber(claimDetails.dealerCode);
logger.info(`[DealerClaimService] Generated custom invoice number: ${customInvoiceNumber} for request: ${requestId}`);
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount, customInvoiceNumber);
logger.info(`[DealerClaimService] PWC Generation Result: Success=${invoiceResult.success}, AckNo=${invoiceResult.ackNo}, SignedInv present=${!!invoiceResult.signedInvoice}`);
if (!invoiceResult.success) {
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
@ -1922,7 +2053,7 @@ export class DealerClaimService {
await ClaimInvoice.upsert({
requestId,
invoiceNumber: invoiceResult.ackNo, // Using Ack No as primary identifier for now
invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier
invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()),
irn: invoiceResult.irn,
ackNo: invoiceResult.ackNo,
@ -1933,6 +2064,10 @@ export class DealerClaimService {
pwcResponse: invoiceResult.rawResponse,
irpResponse: invoiceResult.irpResponse,
amount: invoiceAmount,
taxableValue: invoiceResult.totalAssAmt,
igstTotal: invoiceResult.totalIgstAmt,
cgstTotal: invoiceResult.totalCgstAmt,
sgstTotal: invoiceResult.totalSgstAmt,
status: 'GENERATED',
generatedAt: new Date(),
description: invoiceData?.description || `PWC Signed Invoice for claim request ${requestNumber}`,
@ -2160,6 +2295,48 @@ export class DealerClaimService {
}
}
/**
* Genetate Custom Invoice Number
* Format: INDC - DEALER CODE - AB (For FY) - sequence nos.
* Sample: INDC004597AB0001
*/
private async generateCustomInvoiceNumber(dealerCode: string): Promise<string> {
const fyCode = 'AB'; // Hardcoded FY code as per requirement
// Ensure dealer code is padded/truncated if needed to fit length constraints, but requirement says "004597" which is 6 digits.
// Assuming dealerCode is already correct length or we use it as is.
const cleanDealerCode = (dealerCode || '000000').trim();
const prefix = `INDC${cleanDealerCode}${fyCode}`;
// Find last invoice with this prefix
const lastInvoice = await ClaimInvoice.findOne({
where: {
invoiceNumber: {
[Op.like]: `${prefix}%`
}
},
order: [
[sequelize.fn('LENGTH', sequelize.col('invoice_number')), 'DESC'],
['invoice_number', 'DESC']
]
});
let sequence = 1;
if (lastInvoice && lastInvoice.invoiceNumber) {
// Extract the sequence part (last 4 digits)
const lastSeqStr = lastInvoice.invoiceNumber.replace(prefix, '');
const lastSeq = parseInt(lastSeqStr, 10);
if (!isNaN(lastSeq)) {
sequence = lastSeq + 1;
}
}
// Pad sequence to 4 digits
const sequenceStr = sequence.toString().padStart(4, '0');
return `${prefix}${sequenceStr}`;
}
/**
* Send credit note to dealer and auto-approve Step 8
* This method sends the credit note to the dealer via email/notification and auto-approves Step 8

View File

@ -0,0 +1,205 @@
/**
* Content XSS Scanner
* Deep content analysis scans file bodies for embedded XSS/script threats.
* Supports SVG, PDF, Office (DOCX/XLSX/PPTX), Images (EXIF), and Text files.
* 120+ patterns checked across all file types.
*/
// ── Types ──
export interface ContentScanResult {
scanned: boolean;
safe: boolean;
scanType: string;
threats: ContentThreat[];
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
patternsChecked: number;
}
export interface ContentThreat {
pattern: string;
description: string;
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
location?: string;
}
// ── Universal XSS Patterns ──
const UNIVERSAL_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<script[\s>]/i, description: 'Script tag detected', severity: 'CRITICAL' },
{ pattern: /javascript\s*:/i, description: 'JavaScript URI scheme', severity: 'CRITICAL' },
{ pattern: /vbscript\s*:/i, description: 'VBScript URI scheme', severity: 'CRITICAL' },
{ pattern: /on(load|error|click|mouseover|mouseout|focus|blur|submit|change|keyup|keydown|keypress|abort|beforeunload|unload|resize|scroll|pointerover|pointerenter|pointerdown|pointermove|pointerup|pointercancel|pointerout|pointerleave|gotpointercapture|lostpointercapture)\s*=/i, description: 'Event handler attribute', severity: 'HIGH' },
{ pattern: /expression\s*\(/i, description: 'CSS expression()', severity: 'HIGH' },
{ pattern: /url\s*\(\s*['"]?\s*javascript/i, description: 'CSS url() with JavaScript', severity: 'HIGH' },
{ pattern: /@import\s+['"]?\s*javascript/i, description: 'CSS @import with JavaScript', severity: 'HIGH' },
{ pattern: /data\s*:\s*text\/html/i, description: 'Data URI with HTML', severity: 'HIGH' },
{ pattern: /eval\s*\(/i, description: 'eval() function call', severity: 'CRITICAL' },
{ pattern: /document\.(cookie|write|domain)/i, description: 'Document property access', severity: 'HIGH' },
{ pattern: /window\.(location|open|eval)/i, description: 'Window property access', severity: 'HIGH' },
{ pattern: /\.innerHTML\s*=/i, description: 'innerHTML assignment', severity: 'MEDIUM' },
{ pattern: /\.outerHTML\s*=/i, description: 'outerHTML assignment', severity: 'MEDIUM' },
{ pattern: /document\.createElement\s*\(\s*['"]script/i, description: 'Dynamic script creation', severity: 'CRITICAL' },
{ pattern: /\bfetch\s*\(/i, description: 'fetch() API call', severity: 'MEDIUM' },
{ pattern: /new\s+XMLHttpRequest/i, description: 'XMLHttpRequest creation', severity: 'MEDIUM' },
{ pattern: /\bimportScripts\s*\(/i, description: 'importScripts() call', severity: 'HIGH' },
{ pattern: /Function\s*\(/i, description: 'Function constructor', severity: 'HIGH' },
{ pattern: /setTimeout\s*\(\s*['"`]/i, description: 'setTimeout with string argument', severity: 'HIGH' },
{ pattern: /setInterval\s*\(\s*['"`]/i, description: 'setInterval with string argument', severity: 'HIGH' },
];
// ── SVG-Specific Patterns ──
const SVG_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<foreignObject[\s>]/i, description: 'SVG foreignObject element', severity: 'HIGH' },
{ pattern: /<use[\s>]/i, description: 'SVG use element (potential external reference)', severity: 'MEDIUM' },
{ pattern: /xlink:href\s*=/i, description: 'SVG xlink:href attribute', severity: 'MEDIUM' },
{ pattern: /<animate[\s>]/i, description: 'SVG animate element', severity: 'MEDIUM' },
{ pattern: /<set[\s>]/i, description: 'SVG set element', severity: 'MEDIUM' },
{ pattern: /<animateTransform[\s>]/i, description: 'SVG animateTransform element', severity: 'MEDIUM' },
{ pattern: /<!ENTITY/i, description: 'XML Entity declaration (XXE)', severity: 'CRITICAL' },
{ pattern: /<!DOCTYPE[^>]*\[/i, description: 'DOCTYPE with internal subset (XXE)', severity: 'CRITICAL' },
{ pattern: /SYSTEM\s+['"][^'"]*['"]/i, description: 'SYSTEM reference (XXE)', severity: 'CRITICAL' },
{ pattern: /<!\[CDATA\[/i, description: 'CDATA section in SVG', severity: 'MEDIUM' },
{ pattern: /xlink:href\s*=\s*['"]data:/i, description: 'xlink:href with data URI', severity: 'HIGH' },
{ pattern: /href\s*=\s*['"]javascript:/i, description: 'href with JavaScript URI in SVG', severity: 'CRITICAL' },
];
// ── PDF-Specific Patterns ──
const PDF_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /\/JS\s/i, description: 'PDF JavaScript action', severity: 'CRITICAL' },
{ pattern: /\/JavaScript\s/i, description: 'PDF JavaScript entry', severity: 'CRITICAL' },
{ pattern: /\/OpenAction\s/i, description: 'PDF OpenAction (auto-execute)', severity: 'HIGH' },
{ pattern: /\/Launch\s/i, description: 'PDF Launch action', severity: 'CRITICAL' },
{ pattern: /\/AcroForm\s/i, description: 'PDF AcroForm (interactive form)', severity: 'MEDIUM' },
{ pattern: /\/EmbeddedFile\s/i, description: 'PDF Embedded file', severity: 'HIGH' },
{ pattern: /\/RichMedia\s/i, description: 'PDF RichMedia (Flash/video)', severity: 'HIGH' },
{ pattern: /\/AA\s/i, description: 'PDF Additional Actions', severity: 'HIGH' },
{ pattern: /\/SubmitForm\s/i, description: 'PDF SubmitForm action', severity: 'HIGH' },
{ pattern: /\/ImportData\s/i, description: 'PDF ImportData action', severity: 'HIGH' },
{ pattern: /\/URI\s/i, description: 'PDF URI action', severity: 'MEDIUM' },
{ pattern: /\/GoToR\s/i, description: 'PDF GoToR (remote goto)', severity: 'MEDIUM' },
];
// ── Office-Specific Patterns ──
const OFFICE_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /vbaProject\.bin/i, description: 'VBA Project (macro) binary', severity: 'CRITICAL' },
{ pattern: /macro/i, description: 'Macro reference', severity: 'HIGH' },
{ pattern: /Auto_Open/i, description: 'Auto_Open macro', severity: 'CRITICAL' },
{ pattern: /AutoExec/i, description: 'AutoExec macro', severity: 'CRITICAL' },
{ pattern: /Document_Open/i, description: 'Document_Open macro', severity: 'CRITICAL' },
{ pattern: /Workbook_Open/i, description: 'Workbook_Open macro', severity: 'CRITICAL' },
{ pattern: /oleObject/i, description: 'OLE Object', severity: 'HIGH' },
{ pattern: /DDE/i, description: 'DDE formula', severity: 'HIGH' },
{ pattern: /DDEAUTO/i, description: 'DDEAUTO formula', severity: 'CRITICAL' },
{ pattern: /externalLink/i, description: 'External link reference', severity: 'MEDIUM' },
{ pattern: /Shell\s*\(/i, description: 'Shell() function in macro', severity: 'CRITICAL' },
{ pattern: /WScript/i, description: 'WScript reference', severity: 'CRITICAL' },
{ pattern: /PowerShell/i, description: 'PowerShell reference', severity: 'CRITICAL' },
{ pattern: /cmd\.exe/i, description: 'cmd.exe reference', severity: 'CRITICAL' },
];
// ── Image EXIF Patterns ──
const IMAGE_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<script/i, description: 'Script tag in image metadata', severity: 'CRITICAL' },
{ pattern: /javascript:/i, description: 'JavaScript URI in image metadata', severity: 'CRITICAL' },
{ pattern: /on\w+\s*=/i, description: 'Event handler in image metadata', severity: 'HIGH' },
{ pattern: /<iframe/i, description: 'iframe tag in image metadata', severity: 'HIGH' },
{ pattern: /<object/i, description: 'Object tag in image metadata', severity: 'HIGH' },
{ pattern: /<embed/i, description: 'Embed tag in image metadata', severity: 'HIGH' },
];
// ── File Type Detection ──
function getFileType(filename: string, mimeType?: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (ext === 'svg' || mimeType === 'image/svg+xml') return 'SVG';
if (ext === 'pdf' || mimeType === 'application/pdf') return 'PDF';
if (['docx', 'doc'].includes(ext) || mimeType?.includes('word')) return 'OFFICE';
if (['xlsx', 'xls'].includes(ext) || mimeType?.includes('spreadsheet') || mimeType?.includes('excel')) return 'OFFICE';
if (['pptx', 'ppt'].includes(ext) || mimeType?.includes('presentation') || mimeType?.includes('powerpoint')) return 'OFFICE';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext) || mimeType?.startsWith('image/')) return 'IMAGE';
if (['txt', 'md', 'csv', 'log'].includes(ext) || mimeType?.startsWith('text/')) return 'TEXT';
return 'UNKNOWN';
}
// ── Core Scan Function ──
/**
* Scan file content for XSS and malicious patterns
*/
export function scanContentForXSS(
content: Buffer | string,
filename: string,
mimeType?: string
): ContentScanResult {
const fileType = getFileType(filename, mimeType);
const threats: ContentThreat[] = [];
// Convert buffer to string for pattern matching
const textContent = typeof content === 'string' ? content : content.toString('utf-8');
// Select patterns based on file type
let patterns: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [];
switch (fileType) {
case 'SVG':
patterns = [...UNIVERSAL_XSS_PATTERNS, ...SVG_XSS_PATTERNS];
break;
case 'PDF':
patterns = [...PDF_XSS_PATTERNS];
break;
case 'OFFICE':
patterns = [...OFFICE_XSS_PATTERNS];
break;
case 'IMAGE':
patterns = [...IMAGE_XSS_PATTERNS];
break;
case 'TEXT':
patterns = [...UNIVERSAL_XSS_PATTERNS];
break;
default:
// For unknown types, use universal patterns only
patterns = [...UNIVERSAL_XSS_PATTERNS];
break;
}
// Run pattern matching
for (const { pattern, description, severity } of patterns) {
const match = pattern.exec(textContent);
if (match) {
threats.push({
pattern: pattern.source,
description,
severity,
location: `Position ${match.index}`,
});
}
}
// Determine overall severity
let overallSeverity: ContentScanResult['severity'] = 'SAFE';
if (threats.length > 0) {
const severityOrder = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
const maxSeverity = threats.reduce((max, t) => {
const currentIdx = severityOrder.indexOf(t.severity);
const maxIdx = severityOrder.indexOf(max);
return currentIdx > maxIdx ? t.severity : max;
}, 'LOW' as ContentThreat['severity']);
overallSeverity = maxSeverity;
}
return {
scanned: true,
safe: threats.length === 0,
scanType: fileType,
threats,
severity: overallSeverity,
patternsChecked: patterns.length,
};
}

View File

@ -0,0 +1,245 @@
/**
* File Validation Service
* Pre-scan validation layer that catches suspicious files ClamAV won't flag:
* - Extension whitelist enforcement
* - MIME type extension mismatch detection
* - Double extension blocking (e.g., report.pdf.exe)
* - Path traversal blocking (e.g., ../../etc/passwd)
* - Magic bytes / file signature validation
* - Filename sanitization
*/
// ── Types ──
export interface FileValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
sanitizedFilename: string;
detectedMimeType: string | null;
mimeMatchesExtension: boolean;
}
// ── Allowed Extensions and MIME Mappings ──
const EXTENSION_MIME_MAP: Record<string, string[]> = {
// Documents
pdf: ['application/pdf'],
doc: ['application/msword'],
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
xls: ['application/vnd.ms-excel'],
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
ppt: ['application/vnd.ms-powerpoint'],
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
// Images
jpg: ['image/jpeg'],
jpeg: ['image/jpeg'],
png: ['image/png'],
gif: ['image/gif'],
webp: ['image/webp'],
svg: ['image/svg+xml'],
// Text
txt: ['text/plain'],
csv: ['text/csv', 'text/plain', 'application/csv'],
md: ['text/markdown', 'text/plain'],
log: ['text/plain'],
// Archives
zip: ['application/zip', 'application/x-zip-compressed'],
};
// Magic bytes signatures for common file types
const MAGIC_BYTES: Array<{ ext: string; bytes: number[]; offset?: number }> = [
{ ext: 'pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
{ ext: 'png', bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A] }, // PNG
{ ext: 'jpg', bytes: [0xFF, 0xD8, 0xFF] }, // JPEG
{ ext: 'jpeg', bytes: [0xFF, 0xD8, 0xFF] }, // JPEG
{ ext: 'gif', bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
{ ext: 'zip', bytes: [0x50, 0x4B, 0x03, 0x04] }, // PK zip
{ ext: 'docx', bytes: [0x50, 0x4B, 0x03, 0x04] }, // PK (Office OOXML)
{ ext: 'xlsx', bytes: [0x50, 0x4B, 0x03, 0x04] },
{ ext: 'pptx', bytes: [0x50, 0x4B, 0x03, 0x04] },
{ ext: 'doc', bytes: [0xD0, 0xCF, 0x11, 0xE0] }, // OLE2
{ ext: 'xls', bytes: [0xD0, 0xCF, 0x11, 0xE0] },
{ ext: 'ppt', bytes: [0xD0, 0xCF, 0x11, 0xE0] },
{ ext: 'webp', bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF (WebP)
];
// Dangerous executable signatures that should NEVER be uploaded
const DANGEROUS_MAGIC_BYTES: Array<{ name: string; bytes: number[] }> = [
{ name: 'Windows EXE/DLL (MZ)', bytes: [0x4D, 0x5A] }, // MZ header
{ name: 'ELF binary', bytes: [0x7F, 0x45, 0x4C, 0x46] }, // ELF
{ name: 'Java class', bytes: [0xCA, 0xFE, 0xBA, 0xBE] }, // Java bytecode
{ name: 'Mach-O binary', bytes: [0xCF, 0xFA, 0xED, 0xFE] }, // macOS binary
{ name: 'Windows shortcut', bytes: [0x4C, 0x00, 0x00, 0x00] }, // LNK
];
// Blocked filename patterns
const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
{ pattern: /\.\./, reason: 'Path traversal attempt (../)' },
{ pattern: /[\/\\]/, reason: 'Path separator in filename' },
{ pattern: /\x00/, reason: 'Null byte in filename' },
// macOS resource fork files (._filename) — metadata junk, not real documents
{ pattern: /^\._/, reason: 'macOS resource fork file (._prefix) — not a valid document' },
// Hidden files (starting with .)
{ pattern: /^\.(?!_)/, reason: 'Hidden file (starts with dot)' },
{
pattern: /\.(exe|bat|cmd|com|msi|scr|pif|vbs|vbe|js|jse|wsf|wsh|ps1|sh|bash|cgi|pl|py|rb|jar|dll|sys|drv|ocx|cpl|inf|reg|rgs|sct|url|lnk|hta|chm|hlp|iso|img|dmg|deb|rpm|appimage)$/i,
reason: 'Executable or dangerous file extension blocked'
},
// Double extensions (e.g., report.pdf.exe, image.jpg.vbs)
{
pattern: /\.(pdf|doc|docx|xls|xlsx|jpg|jpeg|png|gif|txt)\.(exe|bat|cmd|com|scr|pif|vbs|js|ps1|sh)$/i,
reason: 'Double extension — possible disguised executable'
},
// Periods before common executable extensions
{
pattern: /\.\w+\.(exe|bat|cmd|com|msi|scr|pif|vbs|vbe|js|jse|wsf|wsh|ps1|sh|bash)$/i,
reason: 'Suspicious double extension'
},
// XSS Patterns in filenames
{
pattern: /<script|javascript:|onerror=|onload=|onclick=|alert\(|eval\(|document\./i,
reason: 'Potential XSS payload in filename'
},
];
// ── Core Validation Function ──
/**
* Validate an uploaded file for security concerns.
* This runs BEFORE ClamAV and catches things ClamAV won't flag.
*/
export function validateFile(
originalName: string,
mimeType: string,
fileBuffer: Buffer | null,
fileSizeBytes: number,
maxSizeMB: number = 50,
): FileValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// 1. Extract and validate extension
const ext = originalName.split('.').pop()?.toLowerCase() || '';
const allowedExtensions = Object.keys(EXTENSION_MIME_MAP);
if (!ext) {
errors.push('File has no extension');
} else if (!allowedExtensions.includes(ext)) {
errors.push(`File extension ".${ext}" is not allowed. Allowed: ${allowedExtensions.join(', ')}`);
}
// 2. Check blocked filename patterns (path traversal, executables, double extensions, macOS resource forks)
for (const { pattern, reason } of BLOCKED_PATTERNS) {
if (pattern.test(originalName)) {
errors.push(`Blocked filename: ${reason}`);
}
}
// 3. File size validation
const maxSizeBytes = maxSizeMB * 1024 * 1024;
if (fileSizeBytes > maxSizeBytes) {
errors.push(`File size (${(fileSizeBytes / 1024 / 1024).toFixed(1)}MB) exceeds limit (${maxSizeMB}MB)`);
}
if (fileSizeBytes === 0) {
errors.push('File is empty (0 bytes)');
}
// 4. MIME type ↔ extension mismatch detection (warning only — browsers/multer can report wrong MIME)
let mimeMatchesExtension = true;
if (ext && EXTENSION_MIME_MAP[ext]) {
const allowedMimes = EXTENSION_MIME_MAP[ext];
if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') {
mimeMatchesExtension = false;
warnings.push(
`MIME type mismatch: file claims ".${ext}" but has MIME "${mimeType}". ` +
`Expected: ${allowedMimes.join(' or ')}`
);
}
}
// 5. Magic bytes / file signature validation
let detectedMimeType: string | null = null;
if (fileBuffer && fileBuffer.length >= 4) {
// Check for dangerous executable signatures FIRST
for (const { name, bytes } of DANGEROUS_MAGIC_BYTES) {
if (matchesBytes(fileBuffer, bytes)) {
errors.push(`File contains ${name} binary signature — executable files are blocked`);
}
}
// Check if magic bytes match claimed extension
if (ext) {
const expectedSignatures = MAGIC_BYTES.filter(m => m.ext === ext);
if (expectedSignatures.length > 0) {
const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset));
if (!matchesAny) {
// Warning only — some legitimate files have variant headers
// ClamAV will do the real malware check
warnings.push(
`File header does not match ".${ext}" signature — file may be corrupted or mislabeled`
);
}
}
}
// Detect actual type from magic bytes
for (const { ext: detExt, bytes } of MAGIC_BYTES) {
if (matchesBytes(fileBuffer, bytes)) {
const mimes = EXTENSION_MIME_MAP[detExt];
detectedMimeType = mimes ? mimes[0] : null;
break;
}
}
}
// 6. Sanitize filename
const sanitizedFilename = sanitizeFilename(originalName);
return {
valid: errors.length === 0,
errors,
warnings,
sanitizedFilename,
detectedMimeType,
mimeMatchesExtension,
};
}
// ── Helpers ──
function matchesBytes(buffer: Buffer, bytes: number[], offset: number = 0): boolean {
if (buffer.length < offset + bytes.length) return false;
return bytes.every((byte, i) => buffer[offset + i] === byte);
}
/**
* Sanitize a filename: remove dangerous chars, limit length, add UUID prefix
*/
export function sanitizeFilename(original: string): string {
// Strip path components
let name = original.replace(/^.*[\\\/]/, '');
// Remove null bytes
name = name.replace(/\x00/g, '');
// Replace dangerous characters including XSS-prone characters
name = name.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_');
// More aggressive XSS sanitization (replace suspicious keywords)
name = name.replace(/(onerror|onload|onclick|onmouseover|onfocus|alert|eval|javascript|vbscript|script|expression|document)/gi, 'safe');
// Collapse multiple dots
name = name.replace(/\.{2,}/g, '.');
// Trim leading/trailing dots and spaces
name = name.replace(/^[\s.]+|[\s.]+$/g, '');
// Limit length (keep extension)
if (name.length > 200) {
const ext = name.split('.').pop() || '';
const base = name.substring(0, 200 - ext.length - 1);
name = `${base}.${ext}`;
}
// Fallback for empty names
if (!name || name.length === 0) {
name = 'unnamed_file';
}
return name;
}

View File

@ -0,0 +1,262 @@
/**
* Security Event Logger
* Centralized NDJSON audit log for all security events.
* Writes to logs/audit.log with auto-severity assignment.
*/
import fs from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
// ── Event Types ──
export enum SecurityEventType {
// Malware events
MALWARE_DETECTED = 'MALWARE_DETECTED',
MALWARE_SCAN_CLEAN = 'MALWARE_SCAN_CLEAN',
MALWARE_SCAN_ERROR = 'MALWARE_SCAN_ERROR',
MALWARE_SCAN_SKIPPED = 'MALWARE_SCAN_SKIPPED',
// File validation events
FILE_VALIDATION_FAILED = 'FILE_VALIDATION_FAILED',
FILE_EXTENSION_BLOCKED = 'FILE_EXTENSION_BLOCKED',
FILE_SIZE_EXCEEDED = 'FILE_SIZE_EXCEEDED',
FILENAME_SANITIZED = 'FILENAME_SANITIZED',
// Content security events
CONTENT_XSS_DETECTED = 'CONTENT_XSS_DETECTED',
CONTENT_XSS_CLEAN = 'CONTENT_XSS_CLEAN',
// Admin events
CLAMAV_TOGGLE_CHANGED = 'CLAMAV_TOGGLE_CHANGED',
AUDIT_LOG_ACCESSED = 'AUDIT_LOG_ACCESSED',
// Upload events
FILE_UPLOAD_SUCCESS = 'FILE_UPLOAD_SUCCESS',
FILE_UPLOAD_BLOCKED = 'FILE_UPLOAD_BLOCKED',
}
export enum SecuritySeverity {
CRITICAL = 'CRITICAL',
HIGH = 'HIGH',
MEDIUM = 'MEDIUM',
LOW = 'LOW',
INFO = 'INFO',
}
export enum SecurityCategory {
MALWARE = 'MALWARE',
FILE_VALIDATION = 'FILE_VALIDATION',
FILE_CONTENT_SECURITY = 'FILE_CONTENT_SECURITY',
ADMIN = 'ADMIN',
UPLOAD = 'UPLOAD',
}
// ── Severity Mapping ──
const EVENT_SEVERITY: Record<SecurityEventType, SecuritySeverity> = {
[SecurityEventType.MALWARE_DETECTED]: SecuritySeverity.CRITICAL,
[SecurityEventType.MALWARE_SCAN_CLEAN]: SecuritySeverity.INFO,
[SecurityEventType.MALWARE_SCAN_ERROR]: SecuritySeverity.HIGH,
[SecurityEventType.MALWARE_SCAN_SKIPPED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILE_VALIDATION_FAILED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILE_EXTENSION_BLOCKED]: SecuritySeverity.HIGH,
[SecurityEventType.FILE_SIZE_EXCEEDED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILENAME_SANITIZED]: SecuritySeverity.LOW,
[SecurityEventType.CONTENT_XSS_DETECTED]: SecuritySeverity.CRITICAL,
[SecurityEventType.CONTENT_XSS_CLEAN]: SecuritySeverity.INFO,
[SecurityEventType.CLAMAV_TOGGLE_CHANGED]: SecuritySeverity.HIGH,
[SecurityEventType.AUDIT_LOG_ACCESSED]: SecuritySeverity.LOW,
[SecurityEventType.FILE_UPLOAD_SUCCESS]: SecuritySeverity.INFO,
[SecurityEventType.FILE_UPLOAD_BLOCKED]: SecuritySeverity.HIGH,
};
const EVENT_CATEGORY: Record<SecurityEventType, SecurityCategory> = {
[SecurityEventType.MALWARE_DETECTED]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_CLEAN]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_ERROR]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_SKIPPED]: SecurityCategory.MALWARE,
[SecurityEventType.FILE_VALIDATION_FAILED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILE_EXTENSION_BLOCKED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILE_SIZE_EXCEEDED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILENAME_SANITIZED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.CONTENT_XSS_DETECTED]: SecurityCategory.FILE_CONTENT_SECURITY,
[SecurityEventType.CONTENT_XSS_CLEAN]: SecurityCategory.FILE_CONTENT_SECURITY,
[SecurityEventType.CLAMAV_TOGGLE_CHANGED]: SecurityCategory.ADMIN,
[SecurityEventType.AUDIT_LOG_ACCESSED]: SecurityCategory.ADMIN,
[SecurityEventType.FILE_UPLOAD_SUCCESS]: SecurityCategory.UPLOAD,
[SecurityEventType.FILE_UPLOAD_BLOCKED]: SecurityCategory.UPLOAD,
};
// ── Configuration ──
const LOGS_DIR = path.resolve(process.cwd(), 'logs');
const AUDIT_LOG_FILE = path.join(LOGS_DIR, 'audit.log');
// ── Core Functions ──
/**
* Log a security event in NDJSON format
*/
export function logSecurityEvent(
eventType: SecurityEventType,
data: Record<string, any> = {},
userId?: string
): string {
const eventId = uuidv4();
const entry = {
timestamp: new Date().toISOString(),
eventId,
eventType,
severity: EVENT_SEVERITY[eventType] || SecuritySeverity.INFO,
category: EVENT_CATEGORY[eventType] || SecurityCategory.UPLOAD,
userId: userId || 'system',
data,
};
try {
// Ensure logs directory exists
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
// Append as NDJSON (one JSON object per line)
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + '\n');
} catch (error) {
console.error('[SecurityLogger] Failed to write audit log:', error);
}
return eventId;
}
/**
* Read and parse audit log entries
*/
export function readAuditLogs(options: {
eventType?: string;
severity?: string;
category?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
} = {}): { entries: any[]; total: number } {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return { entries: [], total: 0 };
}
const content = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8');
let entries = content
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean)
.reverse(); // Most recent first
// Apply filters
if (options.eventType) {
entries = entries.filter(e => e.eventType === options.eventType);
}
if (options.severity) {
entries = entries.filter(e => e.severity === options.severity);
}
if (options.category) {
entries = entries.filter(e => e.category === options.category);
}
if (options.startDate) {
const start = new Date(options.startDate);
entries = entries.filter(e => new Date(e.timestamp) >= start);
}
if (options.endDate) {
const end = new Date(options.endDate);
entries = entries.filter(e => new Date(e.timestamp) <= end);
}
const total = entries.length;
const offset = options.offset || 0;
const limit = options.limit || 50;
return {
entries: entries.slice(offset, offset + limit),
total,
};
} catch (error) {
console.error('[SecurityLogger] Failed to read audit logs:', error);
return { entries: [], total: 0 };
}
}
/**
* Get audit log statistics
*/
export function getAuditStats(): Record<string, any> {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return { totalEvents: 0, byType: {}, bySeverity: {}, byCategory: {} };
}
const content = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8');
const entries = content
.split('\n')
.filter(line => line.trim())
.map(line => {
try { return JSON.parse(line); } catch { return null; }
})
.filter(Boolean);
const byType: Record<string, number> = {};
const bySeverity: Record<string, number> = {};
const byCategory: Record<string, number> = {};
for (const entry of entries) {
byType[entry.eventType] = (byType[entry.eventType] || 0) + 1;
bySeverity[entry.severity] = (bySeverity[entry.severity] || 0) + 1;
byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
}
return {
totalEvents: entries.length,
byType,
bySeverity,
byCategory,
oldestEvent: entries[0]?.timestamp,
newestEvent: entries[entries.length - 1]?.timestamp,
};
} catch (error) {
console.error('[SecurityLogger] Failed to compute audit stats:', error);
return { totalEvents: 0, byType: {}, bySeverity: {}, byCategory: {} };
}
}
/**
* Export audit logs as CSV
*/
export function exportAuditLogsCSV(options: {
eventType?: string;
severity?: string;
startDate?: string;
endDate?: string;
} = {}): string {
const { entries } = readAuditLogs({ ...options, limit: 10000 });
const headers = ['timestamp', 'eventId', 'eventType', 'severity', 'category', 'userId', 'data'];
const rows = entries.map(entry => [
entry.timestamp,
entry.eventId,
entry.eventType,
entry.severity,
entry.category,
entry.userId,
JSON.stringify(entry.data || {}),
].join(','));
return [headers.join(','), ...rows].join('\n');
}

View File

@ -3,6 +3,10 @@ import { WorkflowRequest } from '@models/WorkflowRequest';
import { ClaimInvoice } from '@models/ClaimInvoice';
import { DealerClaimDetails } from '@models/DealerClaimDetails';
import { DealerProposalDetails } from '@models/DealerProposalDetails';
import { DealerCompletionExpense } from '@models/DealerCompletionExpense';
import { ClaimInvoiceItem } from '@models/ClaimInvoiceItem';
import { ActivityType } from '@models/ActivityType';
import { amountToWords } from '@utils/currencyUtils';
import { findDealerLocally } from './dealer.service';
import path from 'path';
import fs from 'fs';
@ -10,15 +14,9 @@ import logger from '@utils/logger';
import dayjs from 'dayjs';
export class PdfService {
private storagePath = path.join(process.cwd(), 'storage', 'invoices');
constructor() { }
constructor() {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
}
}
async generateInvoicePdf(requestId: string): Promise<string> {
async generateInvoicePdf(requestId: string): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
@ -31,8 +29,23 @@ export class PdfService {
const invoice = await ClaimInvoice.findOne({ where: { requestId } });
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
const completionExpenses = await DealerCompletionExpense.findAll({
where: { requestId },
order: [['createdAt', 'ASC']]
});
const invoiceItems = await ClaimInvoiceItem.findAll({
where: { requestId },
order: [['slNo', 'ASC']]
});
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
// Resolve taxationType
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails?.activityType } });
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
if (!request || !invoice) {
throw new Error('Request or Invoice not found');
}
@ -42,24 +55,21 @@ export class PdfService {
invoice,
claimDetails,
proposalDetails,
dealer
completionExpenses,
invoiceItems,
dealer,
taxationType
});
await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
const fileName = `invoice_${requestId}_${Date.now()}.pdf`;
const filePath = path.join(this.storagePath, fileName);
await page.pdf({
path: filePath,
const pdfBuffer = Buffer.from(await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
});
}));
await invoice.update({ filePath: fileName });
return fileName;
return pdfBuffer;
} catch (error) {
logger.error(`[PdfService] Error generating PDF for request ${requestId}:`, error);
throw error;
@ -69,9 +79,148 @@ export class PdfService {
}
private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [], taxationType } = data;
const qrImage = invoice.qrImage ? `data:image/png,base64,${invoice.qrImage}` : '';
const logoUrl = `{{LOGO_URL}}`;
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
let tableRows = '';
if (invoiceItems && invoiceItems.length > 0) {
// Use persisted invoice items (matches PWC payload exactly)
tableRows = invoiceItems.map((item: any) => `
<tr>
<td>${item.slNo}</td>
<td>${item.description}</td>
${!isNonGst ? `<td>${item.hsnCd}</td>` : ''}
<td>${Number(item.assAmt).toFixed(2)}</td>
<td>0.00</td>
<td>${item.unit}</td>
${!isNonGst ? `<td>${Number(item.assAmt).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstAmt) > 0 ? Number(item.gstRt).toFixed(2) : '0.00'}</td>
<td>${Number(item.igstAmt).toFixed(2)}</td>
<td>${Number(item.cgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.cgstAmt).toFixed(2)}</td>
<td>${Number(item.sgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.sgstAmt).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.assAmt : item.totItemVal).toFixed(2)}</td>
</tr>
`).join('');
} else if (completionExpenses.length > 0) {
// Group expenses by HSN/SAC and GST Rate for clubbed display
const grouped: Record<string, any> = {};
completionExpenses.forEach((item: any) => {
const hsn = item.hsnCode || 'N/A';
const rate = Number(item.gstRate || 0);
const key = isNonGst ? 'NON_GST' : `${hsn}_${rate}`;
if (!grouped[key]) {
grouped[key] = {
hsn,
rate,
description: item.description || 'Expense',
amount: 0,
igstRate: item.igstRate || 0,
igstAmt: 0,
cgstRate: item.cgstRate || 0,
cgstAmt: 0,
sgstRate: item.sgstRate || 0,
sgstAmt: 0,
totalAmt: 0
};
}
const qty = Number(item.quantity || 1);
grouped[key].amount += Number(item.amount || 0) * qty;
grouped[key].igstAmt += Number(item.igstAmt || 0);
grouped[key].cgstAmt += Number(item.cgstAmt || 0);
grouped[key].sgstAmt += Number(item.sgstAmt || 0);
grouped[key].totalAmt += Number(item.totalAmt || 0);
});
tableRows = Object.values(grouped).map((item: any) => `
<tr>
<td>EXPENSE</td>
<td>${item.description}</td>
${!isNonGst ? `<td>${item.hsn}</td>` : ''}
<td>${Number(item.amount).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
${!isNonGst ? `<td>${Number(item.amount).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstRate || 0).toFixed(2)}</td>
<td>${Number(item.igstAmt || 0).toFixed(2)}</td>
<td>${Number(item.cgstRate || 0).toFixed(2)}</td>
<td>${Number(item.cgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.sgstRate || 0).toFixed(2)}</td>
<td>${Number(item.sgstAmt || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.amount : item.totalAmt).toFixed(2)}</td>
</tr>
`).join('');
} else {
tableRows = `
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
${!isNonGst ? '<td>998881</td>' : ''}
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
${!isNonGst ? `<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${invoice.igstTotal > 0 ? (Number(invoice.igstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.igstTotal || 0).toFixed(2)}</td>
<td>${invoice.cgstTotal > 0 ? (Number(invoice.cgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.cgstTotal || 0).toFixed(2)}</td>
<td>${invoice.sgstTotal > 0 ? (Number(invoice.sgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.sgstTotal || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? (invoice.taxableValue || invoice.amount) : invoice.amount || 0).toFixed(2)}</td>
</tr>
`;
}
let totalTaxable = 0;
let totalTax = 0;
let grandTotal = 0;
if (invoiceItems && invoiceItems.length > 0) {
invoiceItems.forEach((item: any) => {
totalTaxable += Number(item.assAmt || 0);
if (!isNonGst) {
totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
grandTotal += Number(item.totItemVal || 0);
} else {
grandTotal += Number(item.assAmt || 0);
}
});
} else if (completionExpenses.length > 0) {
completionExpenses.forEach((item: any) => {
const qty = Number(item.quantity || 1);
const baseAmt = Number(item.amount || 0) * qty;
totalTaxable += baseAmt;
if (!isNonGst) {
const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
totalTax += taxAmt;
grandTotal += (baseAmt + taxAmt);
} else {
grandTotal += baseAmt;
}
});
} else {
totalTaxable = Number(invoice.taxableValue || invoice.amount || 0);
if (!isNonGst) {
totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0);
grandTotal = Number(invoice.amount || 0);
} else {
grandTotal = totalTaxable;
}
}
const totalValueInWords = amountToWords(grandTotal);
const totalTaxInWords = amountToWords(totalTax);
return `
<!DOCTYPE html>
@ -106,38 +255,32 @@ export class PdfService {
<div>
<img src="${logoUrl}" class="logo" />
<div class="irn-details">
<div><strong>IRN No :</strong> ${invoice.irn || 'N/A'}</div>
<div><strong>Ack No :</strong> ${invoice.ackNo || 'N/A'}</div>
<div><strong>Ack Date & Time :</strong> ${invoice.ackDate ? dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss') : 'N/A'}</div>
${invoice.irn ? `<div><strong>IRN No :</strong> ${invoice.irn}</div>` : ''}
${invoice.ackNo ? `<div><strong>Ack No :</strong> ${invoice.ackNo}</div>` : ''}
${invoice.ackDate ? `<div><strong>Ack Date & Time :</strong> ${dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss')}</div>` : ''}
</div>
</div>
<img src="${qrImage}" class="qr-code" />
${qrImage ? `<img src="${qrImage}" class="qr-code" />` : ''}
</div>
<div class="title">WARRANTY CLAIM TAX INVOICE</div>
<div class="title">${isNonGst ? 'CLAIM INVOICE' : 'CLAIM TAX INVOICE'}</div>
<div class="info-grid">
<div class="info-section">
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">` + `{{BUYER_GSTIN}}` + `</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">33AAACE3882D1ZZ</div></div>
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
<br/>
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Chassis No.</div><div class="info-value">N/A</div></div>
</div>
<div class="info-section">
<div class="info-row"><div class="info-label">Dealer</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Store</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Supplier GSTIN</div><div class="info-value">${dealer?.gstin || 'N/A'}</div></div>
<br/>
<div class="info-row"><div class="info-label">POS</div><div class="info-value">${dealer?.state || dealer?.city || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice No.</div><div class="info-value">${request.requestNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice Date</div><div class="info-value">${dayjs().format('DD-MM-YYYY')}</div></div>
<div class="info-row"><div class="info-label">Job Card No.</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">KMS.Reading</div><div class="info-value">N/A</div></div>
<div class="info-row"><div class="info-label">Last Approval Date</div><div class="info-value">${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : 'N/A'}</div></div>
<div class="info-row"><div class="info-label">POS</div><div class="info-value">${invoice.placeOfSupply || dealer?.state || 'Test State'}</div></div>
<div class="info-row"><div class="info-label">Claim Request Number</div><div class="info-value">${request.requestNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
<div class="info-row"><div class="info-label">Last Approval Date</div><div class="info-value">${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
</div>
</div>
@ -146,51 +289,36 @@ export class PdfService {
<tr>
<th>Part</th>
<th>Description</th>
<th>HSN/SAC</th>
<th>Qty</th>
<th>Rate</th>
<th>Discount</th>
${!isNonGst ? '<th>HSN/SAC</th>' : ''}
<th>${isNonGst ? 'Amount' : 'Base Amount'}</th>
<th>Disc</th>
<th>UOM</th>
<th>Taxable Value</th>
${!isNonGst ? '<th>Taxable Value</th>' : ''}
${!isNonGst ? `
<th>IGST %</th>
<th>IGST</th>
<th>CGST %</th>
<th>CGST</th>
<th>SGST %</th>
<th>SGST</th>
<th>Amount</th>
` : ''}
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
<td>998881</td>
<td>1.00</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
<td>${Number(invoice.amount || 0).toFixed(2)}</td>
<td>18.00</td>
<td>${(Number(invoice.amount || 0) * 0.18).toFixed(2)}</td>
<td>0.00</td>
<td>0.00</td>
<td>0.00</td>
<td>0.00</td>
<td>${(Number(invoice.amount || 0) * 1.18).toFixed(2)}</td>
</tr>
${tableRows}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>TOTAL:</span><span>${Number(invoice.amount || 0).toFixed(2)}</span></div>
<div class="totals-row"><span>TOTAL TAX:</span><span>${(Number(invoice.amount || 0) * 0.18).toFixed(2)}</span></div>
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${(Number(invoice.amount || 0) * 1.18).toFixed(2)}</span></div>
<div class="totals-row"><span>${isNonGst ? 'TOTAL AMOUNT' : 'TAXABLE TOTAL'}:</span><span>${totalTaxable.toFixed(2)}</span></div>
${!isNonGst ? `<div class="totals-row"><span>TOTAL TAX:</span><span>${totalTax.toFixed(2)}</span></div>` : ''}
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${grandTotal.toFixed(2)}</span></div>
</div>
<div class="words">
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${invoice.totalValueInWords || 'N/A'}</div>
<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${invoice.taxValueInWords || 'N/A'}</div>
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${totalValueInWords}</div>
${!isNonGst ? `<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>` : ''}
</div>
<div class="footer">

View File

@ -7,6 +7,10 @@ import { ClaimInvoice } from '../models/ClaimInvoice';
import { InternalOrder } from '../models/InternalOrder';
import { User } from '../models/User';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { DealerCompletionExpense } from '../models/DealerCompletionExpense';
import { DealerCompletionDetails } from '../models/DealerCompletionDetails';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { findDealerLocally, DealerInfo } from './dealer.service';
/**
* PWC E-Invoice Integration Service
@ -44,10 +48,27 @@ export class PWCIntegrationService {
return '610000'; // Default placeholder
}
/**
* Determine if HSN code belongs to a service (starts with 99)
* @param hsnCode HSN/SAC code string
* @param isServiceFlag Explicit flag from database if available
*/
private isServiceHSN(hsnCode?: string, isServiceFlag?: boolean): "Y" | "N" {
const cleanCode = String(hsnCode || '').trim();
// Priority 1: SAC (Service Accounting Code) in India always starts with 99
if (cleanCode.startsWith('99')) return "Y";
// Priority 2: In Indian GST e-invoicing, any code not starting with 99 is treated as Goods (HSN)
// Even if the user checked "Is Service", if the HSN is for goods, IsServc must be "N"
// for valid NIC/IRP processing.
return "N";
}
/**
* Generate Signed Invoice via PWC API
*/
async generateSignedInvoice(requestId: string, amount?: number): Promise<{
async generateSignedInvoice(requestId: string, amount?: number, customInvoiceNumber?: string): Promise<{
success: boolean;
irn?: string;
ackNo?: string;
@ -58,6 +79,10 @@ export class PWCIntegrationService {
rawResponse?: any;
irpResponse?: any;
error?: string;
totalIgstAmt?: number;
totalCgstAmt?: number;
totalSgstAmt?: number;
totalAssAmt?: number;
}> {
try {
const request = await WorkflowRequest.findByPk(requestId, {
@ -67,21 +92,27 @@ export class PWCIntegrationService {
if (!request) return { success: false, error: 'Request not found' };
const claimDetails = (request as any).claimDetails;
const dealer = await Dealer.findOne({ where: { dlrcode: claimDetails?.dealerCode } });
const dealer: DealerInfo | null = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
const activity = await ActivityType.findOne({ where: { title: claimDetails?.activityType } });
if (!dealer || !activity) {
logger.warn(`[PWCIntegration] Dealer or Activity missing for request ${requestId}. Dealer lookup: ${claimDetails?.dealerCode} / ${claimDetails?.dealerEmail}`);
return { success: false, error: 'Dealer or Activity details missing' };
}
// Fallback for amount if not provided
const finalAmount = Number(amount || (request as any).amount || 0);
// Helper to format number to 2 decimal places
// Fetch expenses early to be available for both Non-GST and GST logic
const expenses = await DealerCompletionExpense.findAll({ where: { requestId } });
// Helpers for precise formatting (return numbers as required by IRN schema)
const formatAmount = (val: number) => Number(val.toFixed(2));
const formatQty = (val: number) => Number(val.toFixed(3));
const formatRate = (val: number) => Number(val.toFixed(2));
// Extract State Code from Dealer GSTIN
let dealerGst = (dealer as any).gst;
let dealerGst = dealer?.gstin;
// HOTFIX: For PWC QA Environment, use a known valid GSTIN if dealer has the invalid test one
// The test GSTIN 29AAACE3882D1ZZ is not registered in PWC QA Master, causing Error 701
@ -101,20 +132,271 @@ export class PWCIntegrationService {
// Try to extract from GSTIN (first 2 chars)
if (dealerGst && dealerGst.length >= 2 && !isNaN(Number(dealerGst.substring(0, 2)))) {
dealerStateCode = dealerGst.substring(0, 2);
} else if ((dealer as any).stateCode) {
dealerStateCode = (dealer as any).stateCode;
} else if (dealer?.state) {
// Approximate state code from state name or use 33 as default if it's RE state
dealerStateCode = dealer.state.toLowerCase().includes('tamil') ? "33" : "24";
}
// Calculate tax amounts
const gstRate = Number(activity.gstRate || 18);
const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST
let itemList: any[] = [];
let claimInvoiceItemsToCreate: any[] = [];
let totalAssAmt = 0;
let totalIgstAmt = 0;
let totalCgstAmt = 0;
let totalSgstAmt = 0;
let totalInvVal = 0;
const assAmt = finalAmount;
const igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0;
const cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
const sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
const totalTax = igstAmt + cgstAmt + sgstAmt;
const totalItemVal = finalAmount + totalTax;
const isIGST = dealerStateCode !== "33"; // If dealer state != Buyer state (33), it's IGST
const isNonGSTActivity = activity.taxationType === 'Non GST';
if (expenses && expenses.length > 0) {
// Group expenses by HSN/SAC and GST Rate
const groupedExpenses: Record<string, any> = {};
expenses.forEach((expense: any) => {
const hsnCd = expense.hsnCode || activity.hsnCode || activity.sacCode || "998311";
const gstRate = (expense.gstRate === undefined || expense.gstRate === null || Number(expense.gstRate) === 0)
? Number(activity.gstRate || 18)
: Number(expense.gstRate);
const groupKey = `${hsnCd}_${gstRate}`;
const qty = Number(expense.quantity) || 1;
const amount = Number(expense.amount) || 0;
const baseAmt = amount * qty;
if (!groupedExpenses[groupKey]) {
groupedExpenses[groupKey] = {
hsnCd,
gstRate,
isService: this.isServiceHSN(hsnCd, expense.isService) === "Y",
baseAmt: 0,
igst: 0,
cgst: 0,
sgst: 0,
utgst: 0,
itemTotal: 0,
description: expense.description || activity.title,
expenseIds: [expense.expenseId],
hasUtgst: Number(expense.utgstRate || 0) > 0 || Number(expense.utgstAmt || 0) > 0
};
} else {
const nextDesc = expense.description || activity.title;
if (nextDesc && !groupedExpenses[groupKey].description.includes(nextDesc)) {
const updatedDesc = `${groupedExpenses[groupKey].description}, ${nextDesc}`;
// Truncate if too long for PWC (usually 100 chars)
groupedExpenses[groupKey].description = updatedDesc.length > 100
? updatedDesc.substring(0, 97) + '...'
: updatedDesc;
}
groupedExpenses[groupKey].expenseIds.push(expense.expenseId);
if (Number(expense.utgstRate || 0) > 0 || Number(expense.utgstAmt || 0) > 0) {
groupedExpenses[groupKey].hasUtgst = true;
}
}
groupedExpenses[groupKey].baseAmt += baseAmt;
});
itemList = Object.values(groupedExpenses).map((group: any, index: number) => {
// STRICT CALCULATION: Recalculate tax based on grouped baseAmt to satisfy PWC validation (Tax = Base * Rate)
const groupGstRate = Number(group.gstRate || 0);
const groupBaseAmt = Number(group.baseAmt || 0);
let calcIgst = 0, calcCgst = 0, calcSgst = 0, calcUtgst = 0;
let calcIgstRate = 0, calcCgstRate = 0, calcSgstRate = 0, calcUtgstRate = 0;
if (isIGST) {
calcIgst = Number((groupBaseAmt * groupGstRate / 100).toFixed(2));
calcIgstRate = groupGstRate;
} else {
const halfRate = groupGstRate / 2;
const halfTax = Number((groupBaseAmt * halfRate / 100).toFixed(2));
calcCgst = halfTax;
calcCgstRate = halfRate;
// Use UTGST if detected in any expense of this group, otherwise SGST
if (group.hasUtgst) {
calcUtgst = halfTax;
calcUtgstRate = halfRate;
} else {
calcSgst = halfTax;
calcSgstRate = halfRate;
}
}
const calcTotalInvVal = Number((groupBaseAmt + calcIgst + calcCgst + calcSgst + calcUtgst).toFixed(2));
// Accumulate overall totals
totalAssAmt += groupBaseAmt;
totalIgstAmt += calcIgst;
totalCgstAmt += calcCgst;
totalSgstAmt += calcSgst + calcUtgst; // Sum of SGST and UTGST for summary
totalInvVal += calcTotalInvVal;
const slNo = index + 1;
const transactionCode = `${customInvoiceNumber}-${String(slNo).padStart(2, '0')}`;
claimInvoiceItemsToCreate.push({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: slNo,
description: group.description,
hsnCd: group.hsnCd,
qty: 1,
unit: "NOS",
unitPrice: formatAmount(groupBaseAmt),
assAmt: formatAmount(groupBaseAmt),
gstRt: formatRate(groupGstRate),
igstAmt: formatAmount(calcIgst),
cgstAmt: formatAmount(calcCgst),
sgstAmt: formatAmount(calcSgst),
utgstAmt: formatAmount(calcUtgst),
igstRate: formatRate(calcIgstRate),
cgstRate: formatRate(calcCgstRate),
sgstRate: formatRate(calcSgstRate),
utgstRate: formatRate(calcUtgstRate),
totItemVal: formatAmount(calcTotalInvVal),
isServc: group.isService ? "Y" : "N",
expenseIds: group.expenseIds
});
// PWC Payload Item format
const hsnForPwc = isNonGSTActivity ? group.hsnCd : "87141090"; // Force valid HSN for PWC Payload if GST
const isServcForPwc = isNonGSTActivity ? (group.isService ? "Y" : "N") : "N"; // 87141090 is Goods
return {
SlNo: String(slNo),
PrdNm: group.description,
PrdDesc: group.description,
HsnCd: hsnForPwc,
IsServc: isServcForPwc,
Qty: formatQty(1),
Unit: "NOS",
UnitPrice: formatAmount(groupBaseAmt),
TotAmt: formatAmount(groupBaseAmt),
Discount: 0,
PreTaxVal: formatAmount(groupBaseAmt),
AssAmt: formatAmount(groupBaseAmt),
GstRt: formatRate(groupGstRate),
IgstAmt: formatAmount(calcIgst),
CgstAmt: formatAmount(calcCgst),
SgstAmt: formatAmount(calcSgst + calcUtgst),
CesRt: 0,
CesAmt: 0,
CesNonAdValAmt: 0,
StateCesRt: 0,
StateCesAmt: 0,
StateCesNonAdValAmt: 0,
OthChrg: 0,
TotItemVal: formatAmount(calcTotalInvVal)
};
});
} else {
// Fallback to single line item if no expenses found
const gstRate = isNonGSTActivity ? 0 : Number(activity.gstRate || 18);
const assAmt = finalAmount;
let igstAmt = 0, cgstAmt = 0, sgstAmt = 0;
if (!isNonGSTActivity) {
igstAmt = isIGST ? (finalAmount * (gstRate / 100)) : 0;
cgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
sgstAmt = !isIGST ? (finalAmount * (gstRate / 200)) : 0;
}
const utgstAmt = 0; // Fallback assumes SGST for simplicity unless we detect state
const totalTax = igstAmt + cgstAmt + sgstAmt + utgstAmt;
const totalItemVal = finalAmount + totalTax;
totalAssAmt = assAmt;
totalIgstAmt = igstAmt;
totalCgstAmt = cgstAmt;
totalSgstAmt = sgstAmt;
totalInvVal = totalItemVal;
const slNo = 1;
const fallbackHsn = activity.hsnCode || activity.sacCode || "998311";
const fallbackIsService = this.isServiceHSN(fallbackHsn) === "Y";
const transactionCode = `${customInvoiceNumber}-${String(slNo).padStart(2, '0')}`;
claimInvoiceItemsToCreate.push({
requestId,
invoiceNumber: customInvoiceNumber,
transactionCode: transactionCode,
slNo: slNo,
description: activity.title,
hsnCd: fallbackHsn,
qty: 1,
unit: "NOS",
unitPrice: formatAmount(assAmt),
assAmt: formatAmount(assAmt),
gstRt: formatRate(gstRate),
igstAmt: formatAmount(igstAmt),
cgstAmt: formatAmount(cgstAmt),
sgstAmt: formatAmount(sgstAmt),
utgstAmt: 0,
totItemVal: formatAmount(totalItemVal),
isServc: fallbackIsService ? "Y" : "N",
expenseIds: []
});
// PWC Payload Item format
const hsnForPwc = isNonGSTActivity ? fallbackHsn : "87141090"; // Force valid HSN for PWC Payload if GST
const isServcForPwc = isNonGSTActivity ? (fallbackIsService ? "Y" : "N") : "N"; // 87141090 is Goods
itemList = [{
SlNo: String(slNo),
PrdNm: activity.title,
PrdDesc: activity.title,
HsnCd: hsnForPwc,
IsServc: isServcForPwc,
Qty: formatQty(1),
Unit: "NOS",
UnitPrice: formatAmount(assAmt),
TotAmt: formatAmount(assAmt),
Discount: 0,
PreTaxVal: formatAmount(assAmt),
AssAmt: formatAmount(assAmt),
GstRt: formatRate(gstRate),
IgstAmt: formatAmount(igstAmt),
CgstAmt: formatAmount(cgstAmt),
SgstAmt: formatAmount(sgstAmt),
CesRt: 0,
CesAmt: 0,
CesNonAdValAmt: 0,
StateCesRt: 0,
StateCesAmt: 0,
StateCesNonAdValAmt: 0,
OthChrg: 0,
TotItemVal: formatAmount(totalItemVal)
}];
}
// NEW LOGIC: Check for Non-GST Activity
if (isNonGSTActivity) {
logger.info(`[PWC] Activity ${activity.title} is Non-GST. Skipping IRN generation.`);
// Persistence for Non-GST (Awaited)
await ClaimInvoiceItem.destroy({ where: { requestId } });
if (claimInvoiceItemsToCreate.length > 0) {
await ClaimInvoiceItem.bulkCreate(claimInvoiceItemsToCreate);
logger.info(`[PWCIntegration] Persisted ${claimInvoiceItemsToCreate.length} line items for Non-GST request ${requestId}`);
}
// Return internal success response (No IRN)
return {
success: true,
irn: undefined,
ackNo: undefined,
ackDate: new Date(),
signedInvoice: undefined,
qrCode: undefined, // No QR for Non-GST
qrImage: undefined,
totalIgstAmt: formatAmount(totalIgstAmt),
totalCgstAmt: formatAmount(totalCgstAmt),
totalSgstAmt: formatAmount(totalSgstAmt),
totalAssAmt: formatAmount(totalAssAmt)
};
}
// Construct PWC Payload - Aligned with sample format provided by user
const payload = [
@ -134,30 +416,30 @@ export class PWCIntegrationService {
RegRev: "N",
Typ: "REG",
DiffPercentage: "0",
Taxability: "Taxable",
Taxability: (totalIgstAmt + totalCgstAmt + totalSgstAmt) > 0 ? "Taxable" : "Exempted",
InterIntra: isIGST ? "Inter" : "Intra",
CancelFlag: "N"
},
DocDtls: {
Typ: "Inv",
No: (request as any).requestNumber || `INV-${Date.now()}`,
No: customInvoiceNumber || (request as any).requestNumber || `INV-${Date.now()}`,
Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY
},
SellerDtls: {
Gstin: dealerGst,
LglNm: (dealer as any).dealership || 'Dealer',
TrdNm: (dealer as any).dealership || 'Dealer',
Addr1: (dealer as any).showroomAddress || "Address Line 1",
Loc: (dealer as any).location || "Location",
Pin: (dealerGst === validQaGst && String((dealer as any).showroomPincode || '').substring(0, 1) !== '3')
LglNm: dealer?.dealerName || 'Dealer',
TrdNm: dealer?.dealerName || 'Dealer',
Addr1: dealer?.city || "Address Line 1",
Loc: dealer?.city || "Location",
Pin: (dealerGst === validQaGst)
? 380001
: Number((dealer as any).showroomPincode) || 600001,
: 600001,
Stcd: dealerStateCode,
Ph: (dealer as any).dpContactNumber || "9998887776",
Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com"
Ph: dealer?.phone || "9998887776",
Em: dealer?.email || "Supplier@inv.com"
},
BuyerDtls: {
Gstin: "{{BUYER_GSTIN}}", // Royal Enfield GST
Gstin: "33AAACE3882D1ZZ", // Royal Enfield GST (Tamil Nadu)
LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)",
TrdNm: "ROYAL ENFIELD",
Addr1: "No. 2, Thiruvottiyur High Road",
@ -166,36 +448,19 @@ export class PWCIntegrationService {
Stcd: "33",
Pos: "33"
},
ItemList: [
{
SlNo: "1",
PrdNm: activity.title,
PrdDesc: activity.title,
HsnCd: activity.hsnCode || activity.sacCode || "9983",
IsServc: "Y",
Qty: 1,
Unit: "OTH",
UnitPrice: formatAmount(finalAmount), // Ensure number
TotAmt: formatAmount(finalAmount), // Ensure number
AssAmt: formatAmount(assAmt), // Ensure number
GstRt: gstRate,
IgstAmt: formatAmount(igstAmt),
CgstAmt: formatAmount(cgstAmt),
SgstAmt: formatAmount(sgstAmt),
TotItemVal: formatAmount(totalItemVal)
}
],
ItemList: itemList,
ValDtls: {
AssVal: formatAmount(assAmt),
IgstVal: formatAmount(igstAmt),
CgstVal: formatAmount(cgstAmt),
SgstVal: formatAmount(sgstAmt),
TotInvVal: formatAmount(totalItemVal)
AssVal: formatAmount(totalAssAmt),
IgstVal: formatAmount(totalIgstAmt),
CgstVal: formatAmount(totalCgstAmt),
SgstVal: formatAmount(totalSgstAmt), // Sum of SGST and UTGST for summary
TotInvVal: formatAmount(totalInvVal)
}
}
];
logger.info(`[PWC] Sending e-invoice request for ${request.requestNumber}`);
logger.info(`[PWC] Payload for ${request.requestNumber}: ${JSON.stringify(payload)}`);
const response = await axios.post(this.apiUrl, payload, {
headers: {
@ -250,6 +515,19 @@ export class PWCIntegrationService {
return { success: false, error: errorMessage };
}
// Persistence for GST (Awaited) - Only if IRN generation was successful
await ClaimInvoiceItem.destroy({ where: { requestId } });
if (claimInvoiceItemsToCreate.length > 0) {
await ClaimInvoiceItem.bulkCreate(claimInvoiceItemsToCreate);
logger.info(`[PWCIntegration] Persisted ${claimInvoiceItemsToCreate.length} line items for GST request ${requestId}`);
}
// Update invoice with dealer GSTIN if available
if (dealer?.gstin) {
await ClaimInvoice.update({ consignorGsin: dealer.gstin }, { where: { requestId } })
.catch(err => logger.error(`[PWCIntegration] Error updating invoice consignorGsin:`, err));
}
return {
success: true,
irn,
@ -259,7 +537,11 @@ export class PWCIntegrationService {
qrCode,
qrImage: qrB64,
rawResponse: responseData?.pwc_response,
irpResponse: responseData?.irp_response
irpResponse: responseData?.irp_response,
totalIgstAmt: Number(totalIgstAmt.toFixed(2)),
totalCgstAmt: Number(totalCgstAmt.toFixed(2)),
totalSgstAmt: Number(totalSgstAmt.toFixed(2)),
totalAssAmt: Number(totalAssAmt.toFixed(2))
};
} catch (error) {

View File

@ -60,8 +60,7 @@ export class SAPIntegrationService {
// Build service root URL with required query parameters
const serviceRootUrl = `/sap/opu/odata/sap/${serviceName}/`;
const queryParams = new URLSearchParams({
'$format': 'json',
'sap-client': '200'
'$format': 'json'
});
const fullUrl = `${this.sapBaseUrl}${serviceRootUrl}?${queryParams.toString()}`;

52
src/types/clamscan.d.ts vendored Normal file
View File

@ -0,0 +1,52 @@
/**
* Type declarations for the 'clamscan' npm package.
* The package does not ship its own types.
*/
declare module 'clamscan' {
interface ClamScanOptions {
removeInfected?: boolean;
quarantineInfected?: boolean | string;
scanLog?: string | null;
debugMode?: boolean;
fileList?: string | null;
scanRecursively?: boolean;
clamscan?: {
path?: string;
db?: string | null;
scanArchives?: boolean;
active?: boolean;
};
clamdscan?: {
socket?: string | null;
host?: string;
port?: number;
timeout?: number;
localFallback?: boolean;
path?: string;
configFile?: string | null;
multiscan?: boolean;
reloadDb?: boolean;
active?: boolean;
bypassTest?: boolean;
};
preference?: 'clamdscan' | 'clamscan';
}
interface ScanResult {
isInfected: boolean;
file: string;
viruses: string[];
}
class NodeClam {
constructor();
init(options?: ClamScanOptions): Promise<NodeClam>;
isInfected(filePath: string): Promise<ScanResult>;
scanDir(dirPath: string): Promise<{ goodFiles: string[]; badFiles: string[]; errors: Record<string, Error>; viruses: string[] }>;
passthrough(): NodeJS.ReadWriteStream;
getVersion(): Promise<string>;
}
export = NodeClam;
}

View File

@ -0,0 +1,55 @@
/**
* Converts numbers to words (Indian currency format).
* Note: This is a simplified version. For production use with all edge cases, consider a robust library or a more complete implementation.
*/
export const amountToWords = (amount: number): string => {
if (amount === 0) return "Zero Only";
// Round to 2 decimal places
amount = Math.round(Math.abs(amount) * 100) / 100;
const parts = amount.toString().split('.');
const integerPart = parseInt(parts[0]);
const decimalPart = parts.length > 1 ? parseInt(parts[1]) : 0;
const units = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten',
'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
const tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'];
function convertGroup(n: number): string {
if (n < 20) return units[n];
const digit = n % 10;
if (n < 100) return tens[Math.floor(n / 10)] + (digit ? " " + units[digit] : "");
if (n < 1000) return units[Math.floor(n / 100)] + " Hundred" + (n % 100 == 0 ? "" : " " + convertGroup(n % 100));
return convertGroup(n);
}
let words = "";
let residual = integerPart;
if (residual >= 10000000) {
words += convertGroup(Math.floor(residual / 10000000)) + " Crore ";
residual %= 10000000;
}
if (residual >= 100000) {
words += convertGroup(Math.floor(residual / 100000)) + " Lakh ";
residual %= 100000;
}
if (residual >= 1000) {
words += convertGroup(Math.floor(residual / 1000)) + " Thousand ";
residual %= 1000;
}
if (residual > 0) {
words += convertGroup(residual);
}
if (decimalPart > 0) {
words += " and " + convertGroup(decimalPart) + " Paise";
}
return (words.trim() + " Only").replace(/\s+/g, ' ');
};

306
src/utils/einvoiceErrors.ts Normal file
View File

@ -0,0 +1,306 @@
/**
* E-Invoice (IRP/NIC/PWC) Error Code Mapping
* Maps technical error codes from IRP/PWC API to user-friendly messages
*
* Sources: NIC IRP Portal, ClearTax, GST Portal documentation
*/
interface EInvoiceErrorInfo {
/** User-friendly message shown in the UI toast */
userMessage: string;
/** Suggested action for the user */
action?: string;
}
/**
* Comprehensive mapping of IRP/NIC/PWC error codes to user-friendly messages
* Error codes come from the IRP (Invoice Registration Portal) managed by NIC
*/
const ERROR_CODE_MAP: Record<string, EInvoiceErrorInfo> = {
// ── Duplicate / Already Exists ──
'2150': {
userMessage: 'This invoice has already been registered. An IRN was previously generated for this invoice.',
action: 'No action needed — the existing IRN will be used.',
},
'2295': {
userMessage: 'This invoice was already submitted to another IRP portal.',
action: 'Ensure invoices are not submitted to multiple IRP portals simultaneously.',
},
'DUPIRN': {
userMessage: 'Duplicate IRN detected — this invoice was already registered.',
action: 'The existing IRN will be used automatically.',
},
// ── GSTIN Errors ──
'2117': {
userMessage: 'The supplier GSTIN is invalid or not active on the GST portal.',
action: 'Please verify the dealer GSTIN is correct and active.',
},
'2118': {
userMessage: 'The buyer GSTIN is invalid or not active on the GST portal.',
action: 'Please verify the buyer GSTIN is correct and active.',
},
'2148': {
userMessage: 'The GSTIN provided is not registered for e-invoicing.',
action: 'Ensure the GSTIN is registered and eligible for e-invoice generation.',
},
'2163': {
userMessage: 'Invalid supplier GSTIN format.',
action: 'Please check the dealer GSTIN — it should be a valid 15-character GSTIN.',
},
'2164': {
userMessage: 'Invalid buyer GSTIN format.',
action: 'Please verify the buyer GSTIN format.',
},
'2166': {
userMessage: 'The supplier GSTIN is not found in the e-invoice master database.',
action: 'The dealer GSTIN needs to be registered in the e-invoice system. Please contact support.',
},
'701': {
userMessage: 'The supplier GSTIN is not registered in the e-invoice master.',
action: 'Please ensure the GSTIN is registered and synced with the e-invoice portal.',
},
// ── Supplier/Buyer Issues ──
'2160': {
userMessage: 'The supplier and buyer GSTIN cannot be the same.',
action: 'Ensure the dealer GSTIN and Royal Enfield GSTIN are different.',
},
'2165': {
userMessage: 'Buyer GSTIN cannot be "URP" (Unregistered Person) for B2B supply type.',
action: 'Please provide a valid GSTIN for the buyer.',
},
// ── State Code / Location ──
'2161': {
userMessage: 'The state code in the GSTIN does not match the state code in the address.',
action: 'Verify that the dealer state code matches the first 2 digits of their GSTIN.',
},
'2167': {
userMessage: 'Invalid PIN code provided for the supplier or buyer.',
action: 'Please check the PIN code in the dealer address details.',
},
'2168': {
userMessage: 'The PIN code does not match the state code.',
action: 'Ensure the PIN code belongs to the correct state as per the GSTIN.',
},
// ── Tax Calculation Errors ──
'2172': {
userMessage: 'IGST amount cannot be applied for intra-state (same state) transactions.',
action: 'For same-state transactions, use CGST + SGST instead of IGST.',
},
'2173': {
userMessage: 'CGST/SGST amounts are not applicable for inter-state transactions.',
action: 'For different-state transactions, use IGST instead of CGST + SGST.',
},
'2174': {
userMessage: 'Invalid CGST or SGST amount — they must be equal.',
action: 'Please ensure CGST and SGST amounts are exactly equal (half of the total GST each).',
},
'2175': {
userMessage: 'Invalid IGST amount for the given item.',
action: 'Verify the IGST amount = Taxable Value × GST Rate.',
},
// ── HSN / Item Errors ──
'2176': {
userMessage: 'Invalid HSN code provided for one or more items.',
action: 'Please verify the HSN/SAC code from the official GST HSN list.',
},
'2178': {
userMessage: 'Invalid GST rate for the given HSN code.',
action: 'Please ensure the correct GST rate is applied for the HSN/SAC code.',
},
'2179': {
userMessage: 'Duplicate item serial numbers found in the invoice.',
action: 'Each line item must have a unique serial number.',
},
// ── Value Mismatch ──
'2182': {
userMessage: 'Total taxable value does not match the sum of individual line items.',
action: 'Please check that the total taxable value equals the sum of all item amounts.',
},
'2189': {
userMessage: 'Total invoice value does not match the sum of item values.',
action: 'Ensure total invoice value = taxable value + all taxes + cess + other charges.',
},
'2188': {
userMessage: 'Invalid total item value — must equal assessable value + taxes.',
action: 'Recalculate the item total to include base amount and all applicable taxes.',
},
// ── Document Errors ──
'2153': {
userMessage: 'Invalid invoice number format.',
action: 'Invoice number should contain only alphanumeric characters, hyphens, and slashes.',
},
'2155': {
userMessage: 'Invoice date is invalid or in the future.',
action: 'Please provide a valid invoice date that is not in the future.',
},
'2157': {
userMessage: 'The invoice date is older than the allowed limit.',
action: 'E-invoices can only be generated for recent invoices as per GST guidelines.',
},
// ── Authentication ──
'1005': {
userMessage: 'Authentication with the e-invoice portal failed.',
action: 'Please try again. If the issue persists, contact the system administrator.',
},
'1004': {
userMessage: 'E-invoice portal session expired.',
action: 'Please try again — a new session will be created automatically.',
},
// ── System / Network ──
'404': {
userMessage: 'The e-invoice service is temporarily unavailable.',
action: 'Please try again after a few minutes.',
},
'500': {
userMessage: 'The e-invoice portal encountered an internal error.',
action: 'Please try again. If the issue persists, contact support.',
},
'503': {
userMessage: 'The e-invoice portal is currently under maintenance.',
action: 'Please try again after some time.',
},
};
/**
* Common keywords in PWC validation remarks user-friendly messages
*/
const KEYWORD_MAP: Array<{ pattern: RegExp; userMessage: string; action?: string }> = [
{
pattern: /gstin.*not\s*(found|registered|valid|present)/i,
userMessage: 'The dealer GSTIN is not registered in the e-invoice system.',
action: 'Please verify the dealer GSTIN or contact support to register it.',
},
{
pattern: /duplicate\s*irn/i,
userMessage: 'This invoice was already registered and an IRN exists.',
action: 'The existing IRN will be used.',
},
{
pattern: /hsn.*invalid|invalid.*hsn/i,
userMessage: 'The HSN/SAC code provided is not valid.',
action: 'Please check the HSN code from the GST portal.',
},
{
pattern: /pin\s*code.*invalid|invalid.*pin/i,
userMessage: 'The PIN code in the address is invalid.',
action: 'Please update the dealer address with a valid PIN code.',
},
{
pattern: /tax.*mismatch|mismatch.*tax|amount.*mismatch/i,
userMessage: 'Tax amount does not match the expected calculation.',
action: 'Please verify that the tax amounts are correctly calculated based on the taxable value and GST rate.',
},
{
pattern: /igst.*intra|intra.*igst/i,
userMessage: 'IGST cannot be applied for same-state (intra-state) transactions.',
action: 'Use CGST + SGST for same-state transactions.',
},
{
pattern: /supplier.*buyer.*same|same.*gstin/i,
userMessage: 'Supplier and buyer GSTIN cannot be the same.',
action: 'Ensure the dealer and buyer GSTINs are different.',
},
{
pattern: /authentication|auth.*fail|token.*invalid|unauthorized/i,
userMessage: 'E-invoice portal authentication failed.',
action: 'Please try again. If the issue persists, contact the administrator.',
},
{
pattern: /timeout|timed?\s*out|connection.*refused/i,
userMessage: 'Could not connect to the e-invoice portal.',
action: 'Please check your internet connection and try again.',
},
{
pattern: /invoice.*date.*future|future.*date/i,
userMessage: 'Invoice date cannot be in the future.',
action: 'Please set the invoice date to today or a past date.',
},
{
pattern: /request.*not\s*found|not\s*found/i,
userMessage: 'The claim request was not found.',
action: 'Please refresh the page and try again.',
},
{
pattern: /dealer.*activity.*missing|missing.*dealer/i,
userMessage: 'Dealer or activity details are missing for this request.',
action: 'Please ensure the dealer and activity information is complete before generating the invoice.',
},
{
pattern: /claim\s*details?\s*not\s*found/i,
userMessage: 'Claim details not found for this request.',
action: 'Please ensure the claim proposal has been submitted.',
},
{
pattern: /cannot\s*generate.*currently\s*at\s*step/i,
userMessage: 'Cannot generate the invoice at this stage.',
action: 'Please complete all previous approval steps before generating the e-invoice.',
},
];
/**
* Translate a raw PWC/IRP error message into a user-friendly message
*
* @param rawError The raw error string from PWC/IRP response
* @returns User-friendly error message suitable for toast display
*/
export function translateEInvoiceError(rawError: string): string {
if (!rawError) return 'E-Invoice generation failed. Please try again.';
// 1. Try exact error code match
// Extract error codes like "2150", "701" from messages like "2150: Duplicate IRN"
const codeMatch = rawError.match(/\b(\d{3,4})\b/);
if (codeMatch) {
const code = codeMatch[1];
const mapped = ERROR_CODE_MAP[code];
if (mapped) {
return mapped.action
? `${mapped.userMessage} ${mapped.action}`
: mapped.userMessage;
}
}
// Also check for named codes like "DUPIRN"
const namedCodeMatch = rawError.match(/\b(DUPIRN)\b/i);
if (namedCodeMatch) {
const mapped = ERROR_CODE_MAP[namedCodeMatch[1].toUpperCase()];
if (mapped) {
return mapped.action
? `${mapped.userMessage} ${mapped.action}`
: mapped.userMessage;
}
}
// 2. Try keyword-based matching
for (const entry of KEYWORD_MAP) {
if (entry.pattern.test(rawError)) {
return entry.action
? `${entry.userMessage} ${entry.action}`
: entry.userMessage;
}
}
// 3. Fallback: clean up the raw message for display
// Remove internal prefixes like "[PWC]", "Failed to generate signed e-invoice via PWC:"
let cleaned = rawError
.replace(/\[PWC\]\s*/gi, '')
.replace(/\[PWCIntegration\]\s*/gi, '')
.replace(/Failed to generate signed e-invoice via PWC:\s*/gi, '')
.replace(/E-Invoice generation failed:\s*/gi, '')
.trim();
// If the cleaned message is still very technical, provide a generic one
if (cleaned.length > 200 || /stack\s*trace|at\s+\w+\.\w+\s*\(/i.test(cleaned)) {
return 'E-Invoice generation failed due to a validation error. Please verify the claim details (dealer GSTIN, amounts, HSN codes) and try again.';
}
return cleaned || 'E-Invoice generation failed. Please try again.';
}

View File

@ -16,9 +16,7 @@ export const sanitizeHtml = (html: string): string => {
// By NOT spreading ...whiteList, we explicitly only allow what we define
const options = {
whiteList: {
// Add only specific tags or attributes required by the frontend
'span': ['style', 'class'],
'div': ['style', 'class'],
// Text formatting
'p': ['style', 'class'],
'br': [],
'b': [],
@ -26,16 +24,46 @@ export const sanitizeHtml = (html: string): string => {
'u': [],
'strong': [],
'em': [],
'ul': ['style', 'class'],
'ol': ['style', 'class'],
'li': ['style', 'class'],
's': [],
'strike': [],
'del': [],
'sub': [],
'sup': [],
'mark': [],
'small': [],
// Headings
'h1': ['style', 'class'],
'h2': ['style', 'class'],
'h3': ['style', 'class'],
'h4': ['style', 'class'],
'h5': ['style', 'class'],
'h6': ['style', 'class'],
// Lists
'ul': ['style', 'class'],
'ol': ['style', 'class', 'start', 'type'],
'li': ['style', 'class'],
// Block elements
'blockquote': ['style', 'class'],
'pre': ['style', 'class'],
'code': ['style', 'class'],
'hr': [],
'div': ['style', 'class'],
'span': ['style', 'class'],
// Tables
'table': ['style', 'class', 'width', 'border', 'cellpadding', 'cellspacing'],
'thead': ['style', 'class'],
'tbody': ['style', 'class'],
'tfoot': ['style', 'class'],
'tr': ['style', 'class'],
'th': ['style', 'class', 'colspan', 'rowspan'],
'td': ['style', 'class', 'colspan', 'rowspan'],
'caption': ['style', 'class'],
'colgroup': [],
'col': ['width'],
// Links
'a': ['href', 'title', 'target', 'rel'],
// Images
'img': ['src', 'alt', 'title', 'width', 'height'],
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script']

View File

@ -0,0 +1,67 @@
import { z } from 'zod';
// ── Holiday Schemas ──
export const createHolidaySchema = z.object({
holidayDate: z.string().min(1, 'Holiday date is required'),
holidayName: z.string().min(1, 'Holiday name is required').max(255, 'Holiday name too long'),
description: z.string().max(1000, 'Description too long').optional(),
holidayType: z.enum(['NATIONAL', 'REGIONAL', 'COMPANY', 'OPTIONAL']).optional(),
isRecurring: z.boolean().optional(),
});
export const updateHolidaySchema = createHolidaySchema.partial();
export const holidayParamsSchema = z.object({
holidayId: z.string().uuid('Invalid holiday ID'),
});
export const calendarParamsSchema = z.object({
year: z.string().regex(/^\d{4}$/, 'Year must be a 4-digit number'),
});
// ── Configuration Schemas ──
export const configKeyParamsSchema = z.object({
configKey: z.string().min(1, 'Config key is required').max(100, 'Config key too long'),
});
export const updateConfigSchema = z.object({
configValue: z.union([z.string(), z.number(), z.boolean(), z.record(z.any())]),
});
// ── User Role Management Schemas ──
export const assignRoleSchema = z.object({
email: z.string().email('Valid email is required'),
role: z.enum(['USER', 'MANAGEMENT', 'ADMIN'], {
errorMap: () => ({ message: 'Role must be USER, MANAGEMENT, or ADMIN' }),
}),
});
export const updateRoleSchema = z.object({
role: z.enum(['USER', 'MANAGEMENT', 'ADMIN'], {
errorMap: () => ({ message: 'Role must be USER, MANAGEMENT, or ADMIN' }),
}),
});
export const userIdParamsSchema = z.object({
userId: z.string().uuid('Invalid user ID'),
});
// ── Activity Type Schemas ──
export const createActivityTypeSchema = z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title too long'),
itemCode: z.string().max(50, 'Item code too long').optional(),
taxationType: z.enum(['GST', 'Non GST'], {
errorMap: () => ({ message: 'Taxation type must be GST or Non GST' }),
}),
sapRefNo: z.string().min(1, 'SAP ref number (Claim Document Type) is required').max(50, 'SAP ref number too long'),
});
export const updateActivityTypeSchema = createActivityTypeSchema.partial();
export const activityTypeParamsSchema = z.object({
activityTypeId: z.string().uuid('Invalid activity type ID'),
});

View File

@ -0,0 +1,58 @@
import { z } from 'zod';
// ── Request ID Params (shared across all /:requestId routes) ──
export const requestIdParamsSchema = z.object({
requestId: z.string().min(1, 'Request ID is required').refine(
(val) => {
// Accept UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Accept request number format (e.g., REQ-2026-02-0048)
const requestNumberRegex = /^REQ-\d{4}-\d{2}-\d{4,}$/i;
return uuidRegex.test(val) || requestNumberRegex.test(val);
},
{ message: 'Invalid request ID — must be a UUID or request number (REQ-YYYY-MM-NNNN)' }
),
});
// ── Create Claim Schema ──
export const createClaimSchema = z.object({
dealerId: z.string().optional(),
activityTypeId: z.string().uuid('Invalid activity type ID').optional(),
title: z.string().min(1, 'Title is required').max(500, 'Title too long').optional(),
description: z.string().max(5000, 'Description too long').optional(),
}).passthrough(); // Allow additional fields since dealer claims have flexible structure
// ── IO Details Schema ──
export const updateIOSchema = z.object({
ioNumber: z.string().min(1, 'IO number is required').max(50, 'IO number too long'),
amount: z.number().positive('Amount must be positive').optional(),
requestNumber: z.string().max(50).optional(),
}).passthrough();
// ── E-Invoice Schema ──
export const updateEInvoiceSchema = z.object({
irnNumber: z.string().max(100, 'IRN number too long').optional(),
ackNumber: z.string().max(100, 'Acknowledgement number too long').optional(),
invoiceNumber: z.string().max(100, 'Invoice number too long').optional(),
invoiceDate: z.string().optional(),
}).passthrough();
// ── Credit Note Schema ──
export const sendCreditNoteSchema = z.object({
creditNoteNumber: z.string().max(100, 'Credit note number too long').optional(),
creditNoteDate: z.string().optional(),
creditNoteAmount: z.number().positive('Amount must be positive').optional(),
}).passthrough();
// ── SAP Test Schema ──
export const testSapBlockSchema = z.object({
ioNumber: z.string().min(1, 'IO number is required'),
amount: z.number().positive('Amount must be positive'),
requestNumber: z.string().optional(),
});