Compare commits
No commits in common. "aa18e3c34d0e7b3ca00882aa0ccf2345cf2c10a5" and "00e1d51c66edcd9bce8971e0f6a85c0ab9268089" have entirely different histories.
aa18e3c34d
...
00e1d51c66
@ -1 +1 @@
|
||||
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};
|
||||
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};
|
||||
60
build/assets/index-5rjlVIR5.js
Normal file
60
build/assets/index-5rjlVIR5.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-AUbBsmWB.css
Normal file
1
build/assets/index-AUbBsmWB.css
Normal file
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
@ -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-DzZrtI-x.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-5rjlVIR5.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-CX5oLBI_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BFJfF1vG.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-DjE6S9VF.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-AUbBsmWB.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -1,8 +1,39 @@
|
||||
# docker-compose.full.yml
|
||||
# Synced with streamlined infrastructure
|
||||
# =============================================================================
|
||||
# RE Workflow - Full Stack Docker Compose
|
||||
# Includes: Application + Database + Monitoring Stack
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose.full.yml up -d
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
@ -19,24 +50,70 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
container_name: re_clamav
|
||||
ports:
|
||||
- "3310:3310"
|
||||
volumes:
|
||||
- clamav_data:/var/lib/clamav
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: re_workflow_backend
|
||||
environment:
|
||||
- CLAMAV_NO_FRESHCLAMD=false
|
||||
healthcheck:
|
||||
test: ["CMD", "clamdcheck"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
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
|
||||
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
|
||||
@ -79,12 +156,15 @@ 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
|
||||
@ -95,13 +175,54 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
clamav_data:
|
||||
loki_data:
|
||||
promtail_data:
|
||||
grafana_data:
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.6.1
|
||||
container_name: re_node_exporter
|
||||
ports:
|
||||
- "9100:9100"
|
||||
networks:
|
||||
- re_workflow_network
|
||||
restart: unless-stopped
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -1,8 +1,28 @@
|
||||
# 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
|
||||
@ -19,88 +39,43 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
container_name: re_clamav
|
||||
ports:
|
||||
- "3310:3310"
|
||||
volumes:
|
||||
- clamav_data:/var/lib/clamav
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: re_workflow_backend
|
||||
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
|
||||
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
|
||||
ports:
|
||||
- "3100:3100"
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
- ./logs:/app/logs
|
||||
- ./uploads:/app/uploads
|
||||
networks:
|
||||
- re_workflow_network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
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"]
|
||||
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
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
clamav_data:
|
||||
loki_data:
|
||||
promtail_data:
|
||||
grafana_data:
|
||||
|
||||
networks:
|
||||
re_workflow_network:
|
||||
|
||||
@ -52,8 +52,6 @@ scrape_configs:
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 10s
|
||||
scrape_timeout: 5s
|
||||
authorization:
|
||||
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
|
||||
|
||||
# ============================================
|
||||
# Node Exporter - Host Metrics
|
||||
|
||||
194
package-lock.json
generated
194
package-lock.json
generated
@ -16,7 +16,6 @@
|
||||
"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",
|
||||
@ -38,7 +37,6 @@
|
||||
"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",
|
||||
@ -61,7 +59,6 @@
|
||||
"@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",
|
||||
@ -3943,16 +3940,6 @@
|
||||
"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",
|
||||
@ -5329,15 +5316,6 @@
|
||||
"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",
|
||||
@ -5783,6 +5761,7 @@
|
||||
"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"
|
||||
@ -5909,61 +5888,6 @@
|
||||
"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",
|
||||
@ -6200,18 +6124,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -6294,6 +6206,7 @@
|
||||
"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"
|
||||
@ -7770,25 +7683,6 @@
|
||||
"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",
|
||||
@ -8101,15 +7995,6 @@
|
||||
"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",
|
||||
@ -9541,24 +9426,6 @@
|
||||
"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",
|
||||
@ -10048,12 +9915,6 @@
|
||||
"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",
|
||||
@ -10396,34 +10257,6 @@
|
||||
"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",
|
||||
@ -11065,20 +10898,6 @@
|
||||
"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",
|
||||
@ -11632,15 +11451,6 @@
|
||||
"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",
|
||||
|
||||
@ -30,7 +30,6 @@
|
||||
"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",
|
||||
@ -52,7 +51,6 @@
|
||||
"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",
|
||||
@ -75,7 +73,6 @@
|
||||
"@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",
|
||||
|
||||
23
src/app.ts
23
src/app.ts
@ -13,8 +13,6 @@ 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
|
||||
@ -27,7 +25,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 === 'development' || process.env.NODE_ENV === 'local';
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
|
||||
// Build connect-src dynamically
|
||||
@ -51,8 +49,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=' '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 '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-attr 'unsafe-inline'",
|
||||
"script-src 'self'",
|
||||
"script-src-elem 'self'",
|
||||
@ -116,12 +114,6 @@ 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'));
|
||||
|
||||
@ -148,15 +140,6 @@ 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 {
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: finalRemark,
|
||||
finalRemark: sanitizedRemark,
|
||||
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' });
|
||||
}
|
||||
|
||||
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS)
|
||||
const sanitizedRemark = sanitizeHtml(finalRemark);
|
||||
|
||||
// Fetch request
|
||||
const request = await WorkflowRequest.findOne({
|
||||
@ -319,7 +319,7 @@ export class ConclusionController {
|
||||
aiGeneratedRemark: null,
|
||||
aiModelUsed: null,
|
||||
aiConfidenceScore: null,
|
||||
finalRemark: finalRemark,
|
||||
finalRemark: sanitizedRemark,
|
||||
editedBy: userId,
|
||||
isEdited: false,
|
||||
editCount: 0,
|
||||
@ -334,7 +334,7 @@ export class ConclusionController {
|
||||
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
||||
|
||||
await conclusion.update({
|
||||
finalRemark: finalRemark,
|
||||
finalRemark: sanitizedRemark,
|
||||
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: finalRemark,
|
||||
conclusionRemark: sanitizedRemark,
|
||||
closureDate: new Date()
|
||||
} as any);
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ 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';
|
||||
@ -12,11 +11,6 @@ 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();
|
||||
@ -757,11 +751,7 @@ export class DealerClaimController {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerClaimController] Error updating e-invoice:', error);
|
||||
|
||||
// Translate technical PWC/IRP error codes to user-friendly messages
|
||||
const userFacingMessage = translateEInvoiceError(errorMessage);
|
||||
|
||||
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
|
||||
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@ -791,28 +781,31 @@ export class DealerClaimController {
|
||||
return ResponseHandler.error(res, 'Invoice record not found', 404);
|
||||
}
|
||||
|
||||
// Generate PDF on the fly
|
||||
// Automatically regenerate PDF to ensure latest template/data is used (useful during testing/fixes)
|
||||
try {
|
||||
const { pdfService } = await import('../services/pdf.service');
|
||||
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
|
||||
await pdfService.generateInvoicePdf(requestId);
|
||||
// Re-fetch invoice to get the new filePath
|
||||
invoice = await ClaimInvoice.findOne({ where: { requestId } });
|
||||
} catch (pdfError) {
|
||||
logger.error(`[DealerClaimController] Failed to auto-regenerate PDF:`, pdfError);
|
||||
// Continue with existing file if regeneration fails
|
||||
}
|
||||
|
||||
const requestNumber = workflow.requestNumber || 'invoice';
|
||||
const fileName = `Invoice_${requestNumber}.pdf`;
|
||||
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="${fileName}"`);
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${invoice.filePath}"`);
|
||||
|
||||
// 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 generate PDF:`, pdfError);
|
||||
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
|
||||
}
|
||||
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);
|
||||
@ -988,98 +981,5 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,6 @@ 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;
|
||||
@ -26,29 +23,6 @@ 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;
|
||||
|
||||
@ -1,433 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -180,58 +180,6 @@ 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
|
||||
// ============================================================================
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
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), // 15 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000', 10),
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||
message: {
|
||||
success: false,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
@ -14,117 +10,4 @@ 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,
|
||||
});
|
||||
@ -1,171 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
|
||||
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');
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
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');
|
||||
},
|
||||
};
|
||||
@ -1,105 +0,0 @@
|
||||
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 };
|
||||
@ -1,231 +0,0 @@
|
||||
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 };
|
||||
@ -11,8 +11,6 @@ interface DealerCompletionExpenseAttributes {
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
@ -24,7 +22,6 @@ interface DealerCompletionExpenseAttributes {
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
isService?: boolean;
|
||||
expenseDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@ -40,8 +37,6 @@ 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;
|
||||
@ -53,7 +48,6 @@ 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;
|
||||
@ -107,17 +101,6 @@ 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,
|
||||
@ -173,12 +156,6 @@ DealerCompletionExpense.init(
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
},
|
||||
isService: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false,
|
||||
field: 'is_service'
|
||||
},
|
||||
expenseDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@ -11,8 +11,6 @@ interface DealerProposalCostItemAttributes {
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
@ -24,7 +22,6 @@ interface DealerProposalCostItemAttributes {
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
isService?: boolean;
|
||||
itemOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@ -40,8 +37,6 @@ 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;
|
||||
@ -53,7 +48,6 @@ 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;
|
||||
@ -108,17 +102,6 @@ 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,
|
||||
@ -174,12 +157,6 @@ DealerProposalCostItem.init(
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
},
|
||||
isService: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false,
|
||||
field: 'is_service'
|
||||
},
|
||||
itemOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
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
|
||||
|
||||
@ -26,9 +26,6 @@ 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 = () => {
|
||||
@ -182,10 +179,7 @@ export {
|
||||
ClaimBudgetTracking,
|
||||
Dealer,
|
||||
ActivityType,
|
||||
DealerClaimHistory,
|
||||
ClaimInvoice,
|
||||
ClaimInvoiceItem,
|
||||
ClaimCreditNote
|
||||
DealerClaimHistory
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
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,
|
||||
@ -59,7 +44,7 @@ router.get('/holidays', getAllHolidays);
|
||||
* @params year
|
||||
* @access Admin
|
||||
*/
|
||||
router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), getHolidayCalendar);
|
||||
router.get('/holidays/calendar/:year', getHolidayCalendar);
|
||||
|
||||
/**
|
||||
* @route POST /api/admin/holidays
|
||||
@ -67,7 +52,7 @@ router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), get
|
||||
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
|
||||
* @access Admin
|
||||
*/
|
||||
router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
|
||||
router.post('/holidays', createHoliday);
|
||||
|
||||
/**
|
||||
* @route PUT /api/admin/holidays/:holidayId
|
||||
@ -76,7 +61,7 @@ router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
|
||||
* @body Holiday fields to update
|
||||
* @access Admin
|
||||
*/
|
||||
router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validateBody(updateHolidaySchema), updateHoliday);
|
||||
router.put('/holidays/:holidayId', updateHoliday);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/admin/holidays/:holidayId
|
||||
@ -84,7 +69,7 @@ router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validate
|
||||
* @params holidayId
|
||||
* @access Admin
|
||||
*/
|
||||
router.delete('/holidays/:holidayId', validateParams(holidayParamsSchema), deleteHoliday);
|
||||
router.delete('/holidays/:holidayId', deleteHoliday);
|
||||
|
||||
/**
|
||||
* @route POST /api/admin/holidays/bulk-import
|
||||
@ -111,7 +96,7 @@ router.get('/configurations', getAllConfigurations);
|
||||
* @body { configValue }
|
||||
* @access Admin
|
||||
*/
|
||||
router.put('/configurations/:configKey', validateParams(configKeyParamsSchema), validateBody(updateConfigSchema), updateConfiguration);
|
||||
router.put('/configurations/:configKey', updateConfiguration);
|
||||
|
||||
/**
|
||||
* @route POST /api/admin/configurations/:configKey/reset
|
||||
@ -119,7 +104,7 @@ router.put('/configurations/:configKey', validateParams(configKeyParamsSchema),
|
||||
* @params configKey
|
||||
* @access Admin
|
||||
*/
|
||||
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
|
||||
router.post('/configurations/:configKey/reset', resetConfiguration);
|
||||
|
||||
// ==================== User Role Management Routes (RBAC) ====================
|
||||
|
||||
@ -129,7 +114,7 @@ router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSc
|
||||
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||
* @access Admin
|
||||
*/
|
||||
router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEmail);
|
||||
router.post('/users/assign-role', assignRoleByEmail);
|
||||
|
||||
/**
|
||||
* @route PUT /api/admin/users/:userId/role
|
||||
@ -138,7 +123,7 @@ router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEm
|
||||
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||
* @access Admin
|
||||
*/
|
||||
router.put('/users/:userId/role', validateParams(userIdParamsSchema), validateBody(updateRoleSchema), updateUserRole);
|
||||
router.put('/users/:userId/role', updateUserRole);
|
||||
|
||||
/**
|
||||
* @route GET /api/admin/users/by-role
|
||||
@ -171,7 +156,7 @@ router.get('/activity-types', getAllActivityTypes);
|
||||
* @params activityTypeId
|
||||
* @access Admin
|
||||
*/
|
||||
router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), getActivityTypeById);
|
||||
router.get('/activity-types/:activityTypeId', getActivityTypeById);
|
||||
|
||||
/**
|
||||
* @route POST /api/admin/activity-types
|
||||
@ -179,7 +164,7 @@ router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsS
|
||||
* @body { title, itemCode?, taxationType?, sapRefNo? }
|
||||
* @access Admin
|
||||
*/
|
||||
router.post('/activity-types', validateBody(createActivityTypeSchema), createActivityType);
|
||||
router.post('/activity-types', createActivityType);
|
||||
|
||||
/**
|
||||
* @route PUT /api/admin/activity-types/:activityTypeId
|
||||
@ -188,7 +173,7 @@ router.post('/activity-types', validateBody(createActivityTypeSchema), createAct
|
||||
* @body Activity type fields to update
|
||||
* @access Admin
|
||||
*/
|
||||
router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), validateBody(updateActivityTypeSchema), updateActivityType);
|
||||
router.put('/activity-types/:activityTypeId', updateActivityType);
|
||||
|
||||
/**
|
||||
* @route DELETE /api/admin/activity-types/:activityTypeId
|
||||
@ -196,6 +181,7 @@ router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsS
|
||||
* @params activityTypeId
|
||||
* @access Admin
|
||||
*/
|
||||
router.delete('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), deleteActivityType);
|
||||
router.delete('/activity-types/:activityTypeId', deleteActivityType);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
@ -1,7 +1,6 @@
|
||||
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();
|
||||
|
||||
@ -13,7 +12,7 @@ router.use(authenticateToken);
|
||||
* @desc Generate AI-powered conclusion remark
|
||||
* @access Private (Initiator only)
|
||||
*/
|
||||
router.post('/:requestId/generate', aiLimiter, (req, res) =>
|
||||
router.post('/:requestId/generate', (req, res) =>
|
||||
conclusionController.generateConclusion(req, res)
|
||||
);
|
||||
|
||||
|
||||
@ -2,14 +2,9 @@ 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
|
||||
@ -32,7 +27,6 @@ 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({
|
||||
@ -48,4 +42,6 @@ router.get('/activity-types',
|
||||
return;
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@ -3,16 +3,6 @@ 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';
|
||||
|
||||
@ -56,63 +46,62 @@ router.post('/', authenticateToken, asyncHandler(dealerClaimController.createCla
|
||||
* @desc Get claim details
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
|
||||
router.get('/:requestId', authenticateToken, 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, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), malwareScanMiddleware, asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
|
||||
router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDocument'), 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, uploadLimiter, validateParams(requestIdParamsSchema), upload.fields([
|
||||
router.post('/:requestId/completion', authenticateToken, upload.fields([
|
||||
{ name: 'completionDocuments', maxCount: 10 },
|
||||
{ name: 'activityPhotos', maxCount: 10 },
|
||||
{ name: 'invoicesReceipts', maxCount: 10 },
|
||||
{ name: 'attendanceSheet', maxCount: 1 },
|
||||
]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
||||
]), 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, sapLimiter, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
|
||||
router.get('/:requestId/io/validate', authenticateToken, 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, sapLimiter, validateParams(requestIdParamsSchema), validateBody(updateIOSchema), asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
|
||||
router.put('/:requestId/io', authenticateToken, 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, sapLimiter, validateParams(requestIdParamsSchema), validateBody(updateEInvoiceSchema), asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
|
||||
router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
|
||||
router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
|
||||
router.get('/:requestId/e-invoice/pdf', authenticateToken, asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
|
||||
|
||||
/**
|
||||
* @route PUT /api/v1/dealer-claims/:requestId/credit-note
|
||||
* @desc Update credit note details (Step 8)
|
||||
* @access Private
|
||||
*/
|
||||
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)));
|
||||
router.put('/:requestId/credit-note', authenticateToken, 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, validateParams(requestIdParamsSchema), validateBody(sendCreditNoteSchema), asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
|
||||
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/dealer-claims/test/sap-block
|
||||
@ -120,6 +109,7 @@ router.post('/:requestId/credit-note/send', authenticateToken, validateParams(re
|
||||
* @access Private
|
||||
* @body { ioNumber: string, amount: number, requestNumber?: string }
|
||||
*/
|
||||
router.post('/test/sap-block', authenticateToken, sapLimiter, validateBody(testSapBlockSchema), asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
|
||||
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ 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';
|
||||
|
||||
@ -21,7 +20,8 @@ const router = Router();
|
||||
const controller = new DocumentController();
|
||||
|
||||
// multipart/form-data: file, requestId, optional category
|
||||
// Middleware chain: auth → multer → malware scan → controller
|
||||
router.post('/', authenticateToken, upload.single('file'), malwareScanMiddleware, asyncHandler(controller.upload.bind(controller)));
|
||||
router.post('/', authenticateToken, upload.single('file'), asyncHandler(controller.upload.bind(controller)));
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
@ -17,18 +17,8 @@ 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();
|
||||
|
||||
@ -41,37 +31,28 @@ router.get('/health', (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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
|
||||
// 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);
|
||||
|
||||
// ── 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)
|
||||
// Add other route modules as they are implemented
|
||||
// router.use('/approvals', approvalRoutes);
|
||||
// router.use('/participants', participantRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@ -11,8 +11,6 @@ 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';
|
||||
@ -103,9 +101,7 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
router.post('/multipart',
|
||||
authenticateToken,
|
||||
uploadLimiter,
|
||||
upload.array('files'),
|
||||
malwareScanMultipleMiddleware,
|
||||
asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController))
|
||||
);
|
||||
|
||||
@ -131,10 +127,8 @@ 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))
|
||||
);
|
||||
|
||||
@ -222,10 +216,8 @@ 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))
|
||||
);
|
||||
|
||||
|
||||
@ -159,8 +159,6 @@ 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 },
|
||||
@ -212,8 +210,6 @@ 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
|
||||
|
||||
@ -48,9 +48,6 @@ 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;
|
||||
@ -60,10 +57,59 @@ interface Migration {
|
||||
// Define all migrations in order
|
||||
// IMPORTANT: Order matters! Dependencies must be created before tables that reference them
|
||||
const migrations: Migration[] = [
|
||||
// ... 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 }
|
||||
// 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 }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
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();
|
||||
@ -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', 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' }
|
||||
{ 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' }
|
||||
];
|
||||
|
||||
/**
|
||||
@ -71,7 +71,7 @@ export async function seedDefaultActivityTypes(): Promise<void> {
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const activityType of DEFAULT_ACTIVITY_TYPES) {
|
||||
const { title, itemCode, taxationType, sapRefNo } = activityType;
|
||||
const { title, itemCode } = activityType;
|
||||
try {
|
||||
// Check if activity type already exists (active or inactive)
|
||||
const existing = await ActivityType.findOne({
|
||||
@ -79,41 +79,45 @@ export async function seedDefaultActivityTypes(): Promise<void> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 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 exists but inactive, reactivate it
|
||||
if (!existing.isActive) {
|
||||
updates.isActive = true;
|
||||
updates.updatedBy = systemUserId;
|
||||
await existing.update(updates);
|
||||
// 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);
|
||||
updatedCount++;
|
||||
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title} (updates: ${JSON.stringify(updates)})`);
|
||||
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title}${!existing.itemCode ? ` (set item_code: ${itemCode})` : ''}`);
|
||||
} else {
|
||||
// Already exists and active
|
||||
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 {
|
||||
// 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})`);
|
||||
}
|
||||
skippedCount++;
|
||||
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new activity type with default fields
|
||||
// Create new activity type with default item_code
|
||||
await ActivityType.create({
|
||||
title,
|
||||
itemCode: itemCode,
|
||||
taxationType: taxationType,
|
||||
sapRefNo: sapRefNo,
|
||||
taxationType: null,
|
||||
sapRefNo: null,
|
||||
isActive: true,
|
||||
createdBy: systemUserId
|
||||
} as any);
|
||||
createdCount++;
|
||||
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode}, sap_ref: ${sapRefNo})`);
|
||||
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode})`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error but continue with other activity types
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -1,6 +1,3 @@
|
||||
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';
|
||||
@ -15,22 +12,19 @@ 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';
|
||||
// findDealerLocally removed (duplicate)
|
||||
|
||||
import { findDealerLocally } from './dealer.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||
|
||||
@ -177,7 +171,7 @@ export class DealerClaimService {
|
||||
levelNumber: a.level,
|
||||
levelName: levelName,
|
||||
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
||||
approverEmail: a.email,
|
||||
approverEmail: `system@${appDomain}`,
|
||||
approverName: a.name || a.email,
|
||||
tatHours: tatHours,
|
||||
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
||||
@ -1083,59 +1077,6 @@ 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
|
||||
@ -1149,15 +1090,7 @@ 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,
|
||||
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
|
||||
amount: Number(item.amount) || 0
|
||||
}));
|
||||
}
|
||||
// Note: costBreakup JSONB field has been removed - only using separate table now
|
||||
@ -1223,14 +1156,7 @@ export class DealerClaimService {
|
||||
const expenseData = expense.toJSON ? expense.toJSON() : expense;
|
||||
return {
|
||||
description: expenseData.description || '',
|
||||
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
|
||||
amount: Number(expenseData.amount) || 0
|
||||
};
|
||||
});
|
||||
|
||||
@ -1332,7 +1258,9 @@ export class DealerClaimService {
|
||||
throw new Error('Failed to get proposal ID after saving proposal details');
|
||||
}
|
||||
|
||||
// Clear existing cost items for this proposal (in case of update)
|
||||
// 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 }
|
||||
});
|
||||
@ -1343,8 +1271,6 @@ export class DealerClaimService {
|
||||
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,
|
||||
@ -1358,12 +1284,12 @@ export class DealerClaimService {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Update budget tracking with proposal estimate
|
||||
await ClaimBudgetTracking.upsert({
|
||||
@ -1488,79 +1414,30 @@ 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) => {
|
||||
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 {
|
||||
const expenseRows = completionData.closedExpenses.map((item: any) => ({
|
||||
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,
|
||||
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) || (baseTotal + totalTaxAmt),
|
||||
isService: !!item.isService,
|
||||
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())),
|
||||
};
|
||||
});
|
||||
}));
|
||||
await DealerCompletionExpense.bulkCreate(expenseRows);
|
||||
}
|
||||
|
||||
@ -2037,15 +1914,7 @@ export class DealerClaimService {
|
||||
|| budgetTracking?.initialEstimatedBudget
|
||||
|| 0;
|
||||
|
||||
// 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}`);
|
||||
const invoiceResult = await pwcIntegrationService.generateSignedInvoice(requestId, invoiceAmount);
|
||||
|
||||
if (!invoiceResult.success) {
|
||||
throw new Error(`Failed to generate signed e-invoice via PWC: ${invoiceResult.error}`);
|
||||
@ -2053,7 +1922,7 @@ export class DealerClaimService {
|
||||
|
||||
await ClaimInvoice.upsert({
|
||||
requestId,
|
||||
invoiceNumber: customInvoiceNumber, // Use custom invoice number as primary identifier
|
||||
invoiceNumber: invoiceResult.ackNo, // Using Ack No as primary identifier for now
|
||||
invoiceDate: invoiceResult.ackDate instanceof Date ? invoiceResult.ackDate : (invoiceResult.ackDate ? new Date(invoiceResult.ackDate) : new Date()),
|
||||
irn: invoiceResult.irn,
|
||||
ackNo: invoiceResult.ackNo,
|
||||
@ -2064,10 +1933,6 @@ 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}`,
|
||||
@ -2295,48 +2160,6 @@ 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
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,262 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@ -3,10 +3,6 @@ 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';
|
||||
@ -14,9 +10,15 @@ import logger from '@utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export class PdfService {
|
||||
constructor() { }
|
||||
private storagePath = path.join(process.cwd(), 'storage', 'invoices');
|
||||
|
||||
async generateInvoicePdf(requestId: string): Promise<Buffer> {
|
||||
constructor() {
|
||||
if (!fs.existsSync(this.storagePath)) {
|
||||
fs.mkdirSync(this.storagePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async generateInvoicePdf(requestId: string): Promise<string> {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
@ -29,23 +31,8 @@ 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');
|
||||
}
|
||||
@ -55,21 +42,24 @@ export class PdfService {
|
||||
invoice,
|
||||
claimDetails,
|
||||
proposalDetails,
|
||||
completionExpenses,
|
||||
invoiceItems,
|
||||
dealer,
|
||||
taxationType
|
||||
dealer
|
||||
});
|
||||
|
||||
await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfBuffer = Buffer.from(await page.pdf({
|
||||
const fileName = `invoice_${requestId}_${Date.now()}.pdf`;
|
||||
const filePath = path.join(this.storagePath, fileName);
|
||||
|
||||
await page.pdf({
|
||||
path: filePath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
|
||||
}));
|
||||
});
|
||||
|
||||
return pdfBuffer;
|
||||
await invoice.update({ filePath: fileName });
|
||||
|
||||
return fileName;
|
||||
} catch (error) {
|
||||
logger.error(`[PdfService] Error generating PDF for request ${requestId}:`, error);
|
||||
throw error;
|
||||
@ -79,148 +69,9 @@ export class PdfService {
|
||||
}
|
||||
|
||||
private getInvoiceHtmlTemplate(data: any): string {
|
||||
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [], taxationType } = data;
|
||||
const qrImage = invoice.qrImage ? `data:image/png,base64,${invoice.qrImage}` : '';
|
||||
const { request, invoice, dealer, claimDetails } = 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>
|
||||
@ -255,32 +106,38 @@ export class PdfService {
|
||||
<div>
|
||||
<img src="${logoUrl}" class="logo" />
|
||||
<div class="irn-details">
|
||||
${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><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>
|
||||
</div>
|
||||
</div>
|
||||
${qrImage ? `<img src="${qrImage}" class="qr-code" />` : ''}
|
||||
<img src="${qrImage}" class="qr-code" />
|
||||
</div>
|
||||
|
||||
<div class="title">${isNonGst ? 'CLAIM INVOICE' : 'CLAIM TAX INVOICE'}</div>
|
||||
<div class="title">WARRANTY 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">33AAACE3882D1ZZ</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 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">${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 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>
|
||||
</div>
|
||||
|
||||
@ -289,36 +146,51 @@ export class PdfService {
|
||||
<tr>
|
||||
<th>Part</th>
|
||||
<th>Description</th>
|
||||
${!isNonGst ? '<th>HSN/SAC</th>' : ''}
|
||||
<th>${isNonGst ? 'Amount' : 'Base Amount'}</th>
|
||||
<th>Disc</th>
|
||||
<th>HSN/SAC</th>
|
||||
<th>Qty</th>
|
||||
<th>Rate</th>
|
||||
<th>Discount</th>
|
||||
<th>UOM</th>
|
||||
${!isNonGst ? '<th>Taxable Value</th>' : ''}
|
||||
${!isNonGst ? `
|
||||
<th>Taxable Value</th>
|
||||
<th>IGST %</th>
|
||||
<th>IGST</th>
|
||||
<th>CGST %</th>
|
||||
<th>CGST</th>
|
||||
<th>SGST %</th>
|
||||
<th>SGST</th>
|
||||
` : ''}
|
||||
<th>Total</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals">
|
||||
<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 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>
|
||||
|
||||
<div class="words">
|
||||
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${totalValueInWords}</div>
|
||||
${!isNonGst ? `<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>` : ''}
|
||||
<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>
|
||||
|
||||
<div class="footer">
|
||||
|
||||
@ -7,10 +7,6 @@ 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
|
||||
@ -48,27 +44,10 @@ 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, customInvoiceNumber?: string): Promise<{
|
||||
async generateSignedInvoice(requestId: string, amount?: number): Promise<{
|
||||
success: boolean;
|
||||
irn?: string;
|
||||
ackNo?: string;
|
||||
@ -79,10 +58,6 @@ export class PWCIntegrationService {
|
||||
rawResponse?: any;
|
||||
irpResponse?: any;
|
||||
error?: string;
|
||||
totalIgstAmt?: number;
|
||||
totalCgstAmt?: number;
|
||||
totalSgstAmt?: number;
|
||||
totalAssAmt?: number;
|
||||
}> {
|
||||
try {
|
||||
const request = await WorkflowRequest.findByPk(requestId, {
|
||||
@ -92,27 +67,21 @@ export class PWCIntegrationService {
|
||||
if (!request) return { success: false, error: 'Request not found' };
|
||||
|
||||
const claimDetails = (request as any).claimDetails;
|
||||
const dealer: DealerInfo | null = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
|
||||
const dealer = await Dealer.findOne({ where: { dlrcode: claimDetails?.dealerCode } });
|
||||
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);
|
||||
|
||||
// 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)
|
||||
// Helper to format number to 2 decimal places
|
||||
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?.gstin;
|
||||
let dealerGst = (dealer as any).gst;
|
||||
|
||||
// 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
|
||||
@ -132,272 +101,21 @@ 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?.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";
|
||||
} else if ((dealer as any).stateCode) {
|
||||
dealerStateCode = (dealer as any).stateCode;
|
||||
}
|
||||
|
||||
let itemList: any[] = [];
|
||||
let claimInvoiceItemsToCreate: any[] = [];
|
||||
let totalAssAmt = 0;
|
||||
let totalIgstAmt = 0;
|
||||
let totalCgstAmt = 0;
|
||||
let totalSgstAmt = 0;
|
||||
let totalInvVal = 0;
|
||||
|
||||
// Calculate tax amounts
|
||||
const gstRate = Number(activity.gstRate || 18);
|
||||
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 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;
|
||||
|
||||
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 = [
|
||||
{
|
||||
@ -416,30 +134,30 @@ export class PWCIntegrationService {
|
||||
RegRev: "N",
|
||||
Typ: "REG",
|
||||
DiffPercentage: "0",
|
||||
Taxability: (totalIgstAmt + totalCgstAmt + totalSgstAmt) > 0 ? "Taxable" : "Exempted",
|
||||
Taxability: "Taxable",
|
||||
InterIntra: isIGST ? "Inter" : "Intra",
|
||||
CancelFlag: "N"
|
||||
},
|
||||
DocDtls: {
|
||||
Typ: "Inv",
|
||||
No: customInvoiceNumber || (request as any).requestNumber || `INV-${Date.now()}`,
|
||||
No: (request as any).requestNumber || `INV-${Date.now()}`,
|
||||
Dt: new Date().toLocaleDateString('en-GB') // DD/MM/YYYY
|
||||
},
|
||||
SellerDtls: {
|
||||
Gstin: dealerGst,
|
||||
LglNm: dealer?.dealerName || 'Dealer',
|
||||
TrdNm: dealer?.dealerName || 'Dealer',
|
||||
Addr1: dealer?.city || "Address Line 1",
|
||||
Loc: dealer?.city || "Location",
|
||||
Pin: (dealerGst === validQaGst)
|
||||
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')
|
||||
? 380001
|
||||
: 600001,
|
||||
: Number((dealer as any).showroomPincode) || 600001,
|
||||
Stcd: dealerStateCode,
|
||||
Ph: dealer?.phone || "9998887776",
|
||||
Em: dealer?.email || "Supplier@inv.com"
|
||||
Ph: (dealer as any).dpContactNumber || "9998887776",
|
||||
Em: (dealer as any).dealerPrincipalEmailId || "Supplier@inv.com"
|
||||
},
|
||||
BuyerDtls: {
|
||||
Gstin: "33AAACE3882D1ZZ", // Royal Enfield GST (Tamil Nadu)
|
||||
Gstin: "{{BUYER_GSTIN}}", // Royal Enfield GST
|
||||
LglNm: "ROYAL ENFIELD (A UNIT OF EICHER MOTORS LTD)",
|
||||
TrdNm: "ROYAL ENFIELD",
|
||||
Addr1: "No. 2, Thiruvottiyur High Road",
|
||||
@ -448,19 +166,36 @@ export class PWCIntegrationService {
|
||||
Stcd: "33",
|
||||
Pos: "33"
|
||||
},
|
||||
ItemList: itemList,
|
||||
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)
|
||||
}
|
||||
],
|
||||
ValDtls: {
|
||||
AssVal: formatAmount(totalAssAmt),
|
||||
IgstVal: formatAmount(totalIgstAmt),
|
||||
CgstVal: formatAmount(totalCgstAmt),
|
||||
SgstVal: formatAmount(totalSgstAmt), // Sum of SGST and UTGST for summary
|
||||
TotInvVal: formatAmount(totalInvVal)
|
||||
AssVal: formatAmount(assAmt),
|
||||
IgstVal: formatAmount(igstAmt),
|
||||
CgstVal: formatAmount(cgstAmt),
|
||||
SgstVal: formatAmount(sgstAmt),
|
||||
TotInvVal: formatAmount(totalItemVal)
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
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: {
|
||||
@ -515,19 +250,6 @@ 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,
|
||||
@ -537,11 +259,7 @@ export class PWCIntegrationService {
|
||||
qrCode,
|
||||
qrImage: qrB64,
|
||||
rawResponse: responseData?.pwc_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))
|
||||
irpResponse: responseData?.irp_response
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@ -60,7 +60,8 @@ export class SAPIntegrationService {
|
||||
// Build service root URL with required query parameters
|
||||
const serviceRootUrl = `/sap/opu/odata/sap/${serviceName}/`;
|
||||
const queryParams = new URLSearchParams({
|
||||
'$format': 'json'
|
||||
'$format': 'json',
|
||||
'sap-client': '200'
|
||||
});
|
||||
const fullUrl = `${this.sapBaseUrl}${serviceRootUrl}?${queryParams.toString()}`;
|
||||
|
||||
|
||||
52
src/types/clamscan.d.ts
vendored
52
src/types/clamscan.d.ts
vendored
@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
|
||||
/**
|
||||
* 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, ' ');
|
||||
};
|
||||
@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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.';
|
||||
}
|
||||
@ -16,7 +16,9 @@ export const sanitizeHtml = (html: string): string => {
|
||||
// By NOT spreading ...whiteList, we explicitly only allow what we define
|
||||
const options = {
|
||||
whiteList: {
|
||||
// Text formatting
|
||||
// Add only specific tags or attributes required by the frontend
|
||||
'span': ['style', 'class'],
|
||||
'div': ['style', 'class'],
|
||||
'p': ['style', 'class'],
|
||||
'br': [],
|
||||
'b': [],
|
||||
@ -24,46 +26,16 @@ export const sanitizeHtml = (html: string): string => {
|
||||
'u': [],
|
||||
'strong': [],
|
||||
'em': [],
|
||||
's': [],
|
||||
'strike': [],
|
||||
'del': [],
|
||||
'sub': [],
|
||||
'sup': [],
|
||||
'mark': [],
|
||||
'small': [],
|
||||
// Headings
|
||||
'ul': ['style', 'class'],
|
||||
'ol': ['style', 'class'],
|
||||
'li': ['style', 'class'],
|
||||
'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']
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
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'),
|
||||
});
|
||||
@ -1,58 +0,0 @@
|
||||
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(),
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user