Compare commits
No commits in common. "8753c9477dda3c558b4678bb2089775a0df843b1" and "798688e4c2523270dce6518e73da9db32cc67b15" have entirely different histories.
8753c9477d
...
798688e4c2
@ -1326,9 +1326,9 @@ GCP_KEY_FILE=./config/gcp-key.json
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||
SMTP_USER=notifications@royalenfield.com
|
||||
SMTP_PASSWORD=your_smtp_password
|
||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||
|
||||
# AI Service (for conclusion generation)
|
||||
AI_API_KEY=your_ai_api_key
|
||||
|
||||
@ -155,13 +155,13 @@ export async function calculateBusinessDays(
|
||||
2. ✅ Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils`
|
||||
3. ✅ Replaced lines 64-65 with proper working hours calculation (now lines 66-77)
|
||||
4. ✅ Gets priority from workflow
|
||||
5. Done: Test TAT breach alerts
|
||||
5. ⏳ **TODO:** Test TAT breach alerts
|
||||
|
||||
### Step 2: Add Business Days Function ✅ **DONE**
|
||||
1. ✅ Opened `Re_Backend/src/utils/tatTimeUtils.ts`
|
||||
2. ✅ Added `calculateBusinessDays()` function (lines 697-758)
|
||||
3. ✅ Exported the function
|
||||
4. Done: Test with various date ranges
|
||||
4. ⏳ **TODO:** Test with various date ranges
|
||||
|
||||
### Step 3: Update Workflow Aging Report ✅ **DONE**
|
||||
1. ✅ Built report endpoint using `calculateBusinessDays()`
|
||||
|
||||
@ -19,10 +19,10 @@ This command will output something like:
|
||||
```
|
||||
=======================================
|
||||
Public Key:
|
||||
{{VAPID_PUBLIC_KEY}}
|
||||
BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||
|
||||
Private Key:
|
||||
{{VAPID_PRIVATE_KEY}}
|
||||
aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||
|
||||
=======================================
|
||||
```
|
||||
@ -59,9 +59,9 @@ Add the generated keys to your backend `.env` file:
|
||||
|
||||
```env
|
||||
# Notification Service Worker credentials (Web Push / VAPID)
|
||||
VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
|
||||
VAPID_PRIVATE_KEY={{VAPID_PRIVATE_KEY}}
|
||||
VAPID_CONTACT=mailto:{{ADMIN_EMAIL}}
|
||||
VAPID_PUBLIC_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||
VAPID_PRIVATE_KEY=aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||
VAPID_CONTACT=mailto:admin@royalenfield.com
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
@ -75,7 +75,7 @@ Add the **SAME** `VAPID_PUBLIC_KEY` to your frontend `.env` file:
|
||||
|
||||
```env
|
||||
# Push Notifications (Web Push / VAPID)
|
||||
VITE_PUBLIC_VAPID_KEY={{VAPID_PUBLIC_KEY}}
|
||||
VITE_PUBLIC_VAPID_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||
```
|
||||
|
||||
**Important:**
|
||||
|
||||
@ -98,7 +98,7 @@ npm run dev
|
||||
1. Server will start automatically
|
||||
2. Log in via SSO
|
||||
3. Run this SQL to make yourself admin:
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@royalenfield.com';
|
||||
|
||||
[Config Seed] ✅ Default configurations seeded successfully (30 settings)
|
||||
info: ✅ Server started successfully on port 5000
|
||||
@ -112,7 +112,7 @@ psql -d royal_enfield_workflow
|
||||
|
||||
UPDATE users
|
||||
SET role = 'ADMIN'
|
||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
WHERE email = 'your-email@royalenfield.com';
|
||||
|
||||
\q
|
||||
```
|
||||
|
||||
@ -471,7 +471,7 @@ The backend supports web push notifications via VAPID (Voluntary Application Ser
|
||||
```
|
||||
VAPID_PUBLIC_KEY=<your-public-key>
|
||||
VAPID_PRIVATE_KEY=<your-private-key>
|
||||
VAPID_CONTACT=mailto:admin@{{APP_DOMAIN}}
|
||||
VAPID_CONTACT=mailto:admin@royalenfield.com
|
||||
```
|
||||
|
||||
3. **Add to Frontend `.env`:**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1
build/assets/charts-vendor-BVfwAPj-.js.map
Normal file
1
build/assets/charts-vendor-BVfwAPj-.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -1 +1,2 @@
|
||||
import{a as s}from"./index-hYhqmPqT.js";import"./radix-vendor-GwO0o3Qg.js";import"./charts-vendor-waDbLeao.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-3qilyUHW.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-Bmv9jJki.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-BgkDE8Pi.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DyksGUTu.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};
|
||||
//# sourceMappingURL=conclusionApi-VENY18zj.js.map
|
||||
1
build/assets/conclusionApi-VENY18zj.js.map
Normal file
1
build/assets/conclusionApi-VENY18zj.js.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"conclusionApi-VENY18zj.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}
|
||||
67
build/assets/index-BgkDE8Pi.js
Normal file
67
build/assets/index-BgkDE8Pi.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-BgkDE8Pi.js.map
Normal file
1
build/assets/index-BgkDE8Pi.js.map
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-D5NCgjQR.css
Normal file
1
build/assets/index-D5NCgjQR.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
1
build/assets/radix-vendor-CYvDqP9X.js.map
Normal file
1
build/assets/radix-vendor-CYvDqP9X.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/router-vendor-B_rK4TXr.js.map
Normal file
1
build/assets/router-vendor-B_rK4TXr.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
build/assets/ui-vendor-DyksGUTu.js
Normal file
3
build/assets/ui-vendor-DyksGUTu.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/ui-vendor-DyksGUTu.js.map
Normal file
1
build/assets/ui-vendor-DyksGUTu.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -10,18 +10,18 @@
|
||||
<meta name="theme-color" content="#2d4a3e" />
|
||||
<title>Royal Enfield | Approval Portal</title>
|
||||
|
||||
<!-- Preload essential fonts and icons -->
|
||||
<!-- Preload critical 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-hYhqmPqT.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js">
|
||||
<script type="module" crossorigin src="/assets/index-BgkDE8Pi.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-DNMmNUQL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.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-Bmv9jJki.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
||||
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://reflow.royalenfield.com</loc>
|
||||
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@ -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:
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
# Dealer Claim Financial Settlement Workflow
|
||||
|
||||
This document outlines the workflow for financial settlement of dealer claims within the Royal Enfield platform, following the transition from direct DMS integration to an Azure File Storage (AFS) based data exchange with SAP.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
The financial settlement process ensures that dealer claims are legally documented and financially settled through Royal Enfield's SAP system.
|
||||
|
||||
### 1. Legal Compliance: PWC E-Invoicing
|
||||
Once the **Dealer Completion Documents** are submitted and approved by the **Initiator (Requestor Evaluation)**, the system triggers the legal compliance step.
|
||||
|
||||
- **Service**: `PWCIntegrationService`
|
||||
- **Action**: Generates a signed E-Invoice via PWC API.
|
||||
- **Output**: IRN (Invoice Reference Number), Ack No, Ack Date, Signed Invoice (PDF/B64), and QR Code.
|
||||
- **Purpose**: Ensures the claim is legally recognized under GST regulations.
|
||||
|
||||
### 2. Financial Posting: AFS/CSV Integration
|
||||
The financial settlement is handled by exchanging data files with SAP via **Azure File Storage (AFS)**.
|
||||
|
||||
- **Action**: The system generates a **CSV file** containing the following details:
|
||||
- Invoice Number (from PWC)
|
||||
- Invoice Amount (with/without GST as per activity type)
|
||||
- GL Code (Resolved based on Activity Type/IO)
|
||||
- Internal Order (IO) Number
|
||||
- Dealer Code
|
||||
- **Storage**: CSV is uploaded to a designated folder in AFS.
|
||||
- **SAP Role**: SAP periodically polls AFS, picks up the CSV, and posts the transaction internally.
|
||||
|
||||
### 3. Payment Outcome: Credit Note
|
||||
The result of the financial posting in SAP is a **Credit Note**.
|
||||
|
||||
- **Workflow**:
|
||||
- SAP generates a Credit Note and uploads it back to AFS.
|
||||
- RE Backend monitors the AFS folder.
|
||||
- Once a Credit Note is detected, the system retrieves it and attaches it to the workflow request.
|
||||
- An email notification (using `creditNoteSent.template.ts`) is sent to the dealer.
|
||||
|
||||
## Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Dealer
|
||||
participant Backend
|
||||
participant PWC
|
||||
participant AFS as Azure File Storage
|
||||
participant SAP
|
||||
|
||||
Dealer->>Backend: Submit Completion Docs (Actuals)
|
||||
Backend->>Backend: Initiator Approval
|
||||
Backend->>PWC: Generate Signed E-Invoice
|
||||
PWC-->>Backend: Return IRN & QR Code
|
||||
Backend->>Backend: Generate Settlement CSV
|
||||
Backend->>AFS: Upload CSV
|
||||
SAP->>AFS: Pick up CSV
|
||||
SAP->>SAP: Post Financials
|
||||
SAP->>AFS: Upload Credit Note
|
||||
Backend->>AFS: Poll/Retrieve Credit Note
|
||||
Backend->>Dealer: Send Credit Note Notification
|
||||
```
|
||||
|
||||
## GL Code Resolution
|
||||
The GL Code is solved dynamically based on:
|
||||
1. **Activity Type**: Each activity (e.g., Marketing Event, Demo) has a primary GL mapping.
|
||||
2. **Internal Order (IO)**: If specific IO logic is required, the GL can be overridden.
|
||||
|
||||
## Summary of Integration Points
|
||||
| Component | Integration Type | Responsibility |
|
||||
| :--- | :--- | :--- |
|
||||
| **PWC** | REST API | Legal E-Invoice |
|
||||
| **AFS (Azure)** | File Storage SDK | CSV Exchange |
|
||||
| **SAP** | Batch Processing | Financial Posting & Credit Note |
|
||||
@ -34,7 +34,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
||||
- **Approver Type**: System (Auto-processed)
|
||||
- **Action Type**: **AUTO** (System automatically creates activity)
|
||||
- **TAT**: 1 hour
|
||||
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
||||
- **Mapping**: System user (`system@royalenfield.com`)
|
||||
- **Status**: Auto-approved when triggered
|
||||
|
||||
### Step 5: Dealer Completion Documents
|
||||
@ -55,7 +55,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
||||
- **Approver Type**: System (Auto-processed via DMS)
|
||||
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
||||
- **TAT**: 1 hour
|
||||
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
||||
- **Mapping**: System user (`system@royalenfield.com`)
|
||||
- **Status**: Auto-approved when triggered
|
||||
|
||||
### Step 8: Credit Note Confirmation
|
||||
@ -121,7 +121,7 @@ const dealerUser = await User.findOne({ where: { email: dealerEmail } });
|
||||
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
||||
2. Find user with designation containing "Finance" or "Accountant"
|
||||
3. Use configured finance team email from admin_configurations table
|
||||
4. Fallback: Use default finance email (e.g., finance@{{APP_DOMAIN}})
|
||||
4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@ -112,7 +112,7 @@ Your CSV file must have these **44 columns** in the following order:
|
||||
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
|
||||
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
|
||||
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
|
||||
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
|
||||
| `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) |
|
||||
| `replacement` | String(50) | No | Replacement status |
|
||||
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
|
||||
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
|
||||
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
|
||||
|
||||
```csv
|
||||
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
|
||||
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
||||
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.royalenfield.com,,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
||||
```
|
||||
|
||||
**What gets auto-generated:**
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# Dealer Integration Implementation Status
|
||||
|
||||
This document summarizes the changes made to integrate the external Royal Enfield Dealer API and implement the dealer validation logic during request creation.
|
||||
|
||||
## Completed Work
|
||||
|
||||
### 1. External Dealer API Integration
|
||||
- **Service**: `DealerExternalService` in `src/services/dealerExternal.service.ts`
|
||||
- Implemented `getDealerByCode` to fetch data from `https://api-uat2.royalenfield.com/DealerMaster`.
|
||||
- Returns real-time GSTIN, Address, and location details.
|
||||
- **Controller & Routes**: Integrated under `/api/v1/dealers-external/search/:dealerCode`.
|
||||
- **Enrichment**: `DealerService.getDealerByCode` now automatically merges this external data into the system's `DealerInfo`, benefiting PWC and PDF generation services.
|
||||
|
||||
### 2. Dealer Validation & Field Mapping Fix
|
||||
- **Strategic Mapping**: Based on requirement, all dealer codes are now mapped against the `employeeNumber` field (HR ID) in the `User` model, not `employeeId`.
|
||||
- **User Enrichment Service**: `validateDealerUser(dealerCode)` now searches by `employeeNumber`.
|
||||
- **SSO Alignment**: `AuthService.ts` now extracts `dealer_code` from the authentication response and persists it to `employeeNumber`.
|
||||
- **Dealer Service**: `getDealerByCode` uses jobTitle-based validation against the `User` table as the primary lookup.
|
||||
|
||||
### 3. Claim Workflow Integration
|
||||
- **Dealer Claim Service**: `createClaimRequest` validates the dealer immediately and overrides approver steps 1 and 4 with the validated user.
|
||||
- **Workflow Controller**: Enforces dealer validation for all `DEALER CLAIM` templates and any request containing a `dealerCode`.
|
||||
|
||||
### 4. E-Invoice & PDF Alignment
|
||||
- **PWC Integration**: `generateSignedInvoice` now uses the enriched `DealerInfo` containing the correct external GSTIN and state code.
|
||||
- **Invoice PDF**: `PdfService` correctly displays the external dealer name, GSTIN, and POS from the source of truth.
|
||||
|
||||
## Conclusion
|
||||
All integrated components have been verified via test scripts and end-to-end flow analysis. The dependency on the local `dealers` table has been successfully minimized, and the system now relies on the `User` table and External API as the primary sources of dealer information.
|
||||
@ -56,7 +56,7 @@ users {
|
||||
```json
|
||||
{
|
||||
"userId": "uuid-1",
|
||||
"email": "john.doe@{{APP_DOMAIN}}",
|
||||
"email": "john.doe@royalenfield.com",
|
||||
"employeeId": "E12345", // Regular employee ID
|
||||
"designation": "Software Engineer",
|
||||
"department": "IT",
|
||||
@ -68,7 +68,7 @@ users {
|
||||
```json
|
||||
{
|
||||
"userId": "uuid-2",
|
||||
"email": "test.2@{{APP_DOMAIN}}",
|
||||
"email": "test.2@royalenfield.com",
|
||||
"employeeId": "RE-MH-001", // Dealer code stored here
|
||||
"designation": "Dealer",
|
||||
"department": "Dealer Operations",
|
||||
|
||||
@ -98,8 +98,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
||||
|
||||
**Base URL Examples:**
|
||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
|
||||
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
||||
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
||||
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice`
|
||||
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice`
|
||||
|
||||
### 3.2 Request Headers
|
||||
|
||||
@ -205,8 +205,8 @@ User-Agent: DMS-Webhook-Client/1.0
|
||||
|
||||
**Base URL Examples:**
|
||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
|
||||
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
||||
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
||||
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note`
|
||||
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note`
|
||||
|
||||
### 4.2 Request Headers
|
||||
|
||||
@ -563,8 +563,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
||||
| Environment | Invoice Webhook URL | Credit Note Webhook URL |
|
||||
|-------------|---------------------|-------------------------|
|
||||
| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` |
|
||||
| UAT | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
||||
| Production | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
||||
| UAT | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` |
|
||||
| Production | `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -157,7 +157,7 @@ npm run seed:config
|
||||
```bash
|
||||
# Edit the script
|
||||
nano scripts/assign-admin-user.sql
|
||||
# Change: YOUR_EMAIL@{{APP_DOMAIN}}
|
||||
# Change: YOUR_EMAIL@royalenfield.com
|
||||
|
||||
# Run it
|
||||
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
|
||||
@ -170,7 +170,7 @@ psql -d royal_enfield_workflow
|
||||
|
||||
UPDATE users
|
||||
SET role = 'ADMIN'
|
||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
WHERE email = 'your-email@royalenfield.com';
|
||||
|
||||
-- Verify
|
||||
SELECT email, role FROM users WHERE role = 'ADMIN';
|
||||
@ -188,7 +188,7 @@ psql -d royal_enfield_workflow -c "\dt"
|
||||
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
|
||||
|
||||
# Check your user
|
||||
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';"
|
||||
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@royalenfield.com';"
|
||||
```
|
||||
|
||||
---
|
||||
@ -241,13 +241,13 @@ Expected output:
|
||||
```sql
|
||||
-- Single user
|
||||
UPDATE users SET role = 'MANAGEMENT'
|
||||
WHERE email = 'manager@{{APP_DOMAIN}}';
|
||||
WHERE email = 'manager@royalenfield.com';
|
||||
|
||||
-- Multiple users
|
||||
UPDATE users SET role = 'MANAGEMENT'
|
||||
WHERE email IN (
|
||||
'manager1@{{APP_DOMAIN}}',
|
||||
'manager2@{{APP_DOMAIN}}'
|
||||
'manager1@royalenfield.com',
|
||||
'manager2@royalenfield.com'
|
||||
);
|
||||
|
||||
-- By department
|
||||
@ -260,13 +260,13 @@ WHERE department = 'Management' AND is_active = true;
|
||||
```sql
|
||||
-- Single user
|
||||
UPDATE users SET role = 'ADMIN'
|
||||
WHERE email = 'admin@{{APP_DOMAIN}}';
|
||||
WHERE email = 'admin@royalenfield.com';
|
||||
|
||||
-- Multiple admins
|
||||
UPDATE users SET role = 'ADMIN'
|
||||
WHERE email IN (
|
||||
'admin1@{{APP_DOMAIN}}',
|
||||
'admin2@{{APP_DOMAIN}}'
|
||||
'admin1@royalenfield.com',
|
||||
'admin2@royalenfield.com'
|
||||
);
|
||||
|
||||
-- By department
|
||||
@ -331,7 +331,7 @@ SELECT
|
||||
mobile_phone,
|
||||
array_length(ad_groups, 1) as ad_group_count
|
||||
FROM users
|
||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
WHERE email = 'your-email@royalenfield.com';
|
||||
```
|
||||
|
||||
---
|
||||
@ -344,7 +344,7 @@ WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@{{APP_DOMAIN}}",
|
||||
"email": "test@royalenfield.com",
|
||||
"displayName": "Test User",
|
||||
"oktaSub": "test-sub-123"
|
||||
}'
|
||||
@ -353,14 +353,14 @@ curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
||||
### 2. Check User Created with Default Role
|
||||
|
||||
```sql
|
||||
SELECT email, role FROM users WHERE email = 'test@{{APP_DOMAIN}}';
|
||||
SELECT email, role FROM users WHERE email = 'test@royalenfield.com';
|
||||
-- Expected: role = 'USER'
|
||||
```
|
||||
|
||||
### 3. Update to ADMIN
|
||||
|
||||
```sql
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
|
||||
```
|
||||
|
||||
### 4. Verify API Access
|
||||
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
|
||||
# Login and get token
|
||||
curl -X POST http://localhost:5000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@{{APP_DOMAIN}}", ...}'
|
||||
-d '{"email": "test@royalenfield.com", ...}'
|
||||
|
||||
# Try admin endpoint (should work if ADMIN role)
|
||||
curl http://localhost:5000/api/v1/admin/configurations \
|
||||
@ -449,7 +449,7 @@ npm run migrate
|
||||
|
||||
```sql
|
||||
-- Check if user exists
|
||||
SELECT * FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
SELECT * FROM users WHERE email = 'your-email@royalenfield.com';
|
||||
|
||||
-- Check Okta sub
|
||||
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
||||
@ -459,7 +459,7 @@ SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
||||
|
||||
```sql
|
||||
-- Verify role
|
||||
SELECT email, role, is_active FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
SELECT email, role, is_active FROM users WHERE email = 'your-email@royalenfield.com';
|
||||
|
||||
-- Check role enum
|
||||
\dT+ user_role_enum
|
||||
|
||||
@ -29,7 +29,7 @@ This guide provides step-by-step instructions for setting up Google Cloud Storag
|
||||
|------|------------------|
|
||||
| **Application** | Royal Enfield Workflow System |
|
||||
| **Environment** | Production |
|
||||
| **Domain** | `https://reflow.{{APP_DOMAIN}}` |
|
||||
| **Domain** | `https://reflow.royalenfield.com` |
|
||||
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
|
||||
| **Storage Type** | Google Cloud Storage (GCS) |
|
||||
| **Region** | `asia-south1` (Mumbai) |
|
||||
@ -325,8 +325,8 @@ Create `cors-config-prod.json`:
|
||||
[
|
||||
{
|
||||
"origin": [
|
||||
"https://reflow.{{APP_DOMAIN}}",
|
||||
"https://www.{{APP_DOMAIN}}"
|
||||
"https://reflow.royalenfield.com",
|
||||
"https://www.royalenfield.com"
|
||||
],
|
||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||
"responseHeader": [
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|------|-------|
|
||||
| **Application** | RE Workflow System |
|
||||
| **Environment** | UAT |
|
||||
| **Domain** | https://reflow-uat.{{APP_DOMAIN}} |
|
||||
| **Domain** | https://reflow-uat.royalenfield.com |
|
||||
| **Purpose** | Store workflow documents and attachments |
|
||||
|
||||
---
|
||||
@ -131,8 +131,8 @@ Apply this CORS policy to allow browser uploads:
|
||||
[
|
||||
{
|
||||
"origin": [
|
||||
"https://reflow-uat.{{APP_DOMAIN}}",
|
||||
"https://reflow.{{APP_DOMAIN}}"
|
||||
"https://reflow-uat.royalenfield.com",
|
||||
"https://reflow.royalenfield.com"
|
||||
],
|
||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||
"responseHeader": [
|
||||
|
||||
@ -72,8 +72,8 @@ The Users API returns a complete user object:
|
||||
"employeeID": "E09994",
|
||||
"title": "Supports Business Applications (SAP) portfolio",
|
||||
"department": "Deputy Manager - Digital & IT",
|
||||
"login": "sanjaysahu@{{APP_DOMAIN}}",
|
||||
"email": "sanjaysahu@{{APP_DOMAIN}}"
|
||||
"login": "sanjaysahu@Royalenfield.com",
|
||||
"email": "sanjaysahu@royalenfield.com"
|
||||
},
|
||||
...
|
||||
}
|
||||
@ -127,7 +127,7 @@ Example log:
|
||||
### Test with curl
|
||||
|
||||
```bash
|
||||
curl --location 'https://{{IDP_DOMAIN}}/api/v1/users/testuser10@eichergroup.com' \
|
||||
curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \
|
||||
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
|
||||
--header 'Accept: application/json'
|
||||
```
|
||||
|
||||
@ -450,16 +450,16 @@ Before Migration:
|
||||
+-------------------------+-----------+
|
||||
| email | is_admin |
|
||||
+-------------------------+-----------+
|
||||
| admin@{{APP_DOMAIN}} | true |
|
||||
| user1@{{APP_DOMAIN}} | false |
|
||||
| admin@royalenfield.com | true |
|
||||
| user1@royalenfield.com | false |
|
||||
+-------------------------+-----------+
|
||||
|
||||
After Migration:
|
||||
+-------------------------+-----------+-----------+
|
||||
| email | role | is_admin |
|
||||
+-------------------------+-----------+-----------+
|
||||
| admin@{{APP_DOMAIN}} | ADMIN | true |
|
||||
| user1@{{APP_DOMAIN}} | USER | false |
|
||||
| admin@royalenfield.com | ADMIN | true |
|
||||
| user1@royalenfield.com | USER | false |
|
||||
+-------------------------+-----------+-----------+
|
||||
```
|
||||
|
||||
@ -473,17 +473,17 @@ After Migration:
|
||||
-- Make user a MANAGEMENT role
|
||||
UPDATE users
|
||||
SET role = 'MANAGEMENT', is_admin = false
|
||||
WHERE email = 'manager@{{APP_DOMAIN}}';
|
||||
WHERE email = 'manager@royalenfield.com';
|
||||
|
||||
-- Make user an ADMIN role
|
||||
UPDATE users
|
||||
SET role = 'ADMIN', is_admin = true
|
||||
WHERE email = 'admin@{{APP_DOMAIN}}';
|
||||
WHERE email = 'admin@royalenfield.com';
|
||||
|
||||
-- Revert to USER role
|
||||
UPDATE users
|
||||
SET role = 'USER', is_admin = false
|
||||
WHERE email = 'user@{{APP_DOMAIN}}';
|
||||
WHERE email = 'user@royalenfield.com';
|
||||
```
|
||||
|
||||
### Via API (Admin Endpoint)
|
||||
|
||||
@ -47,12 +47,12 @@ psql -d royal_enfield_db -f scripts/assign-user-roles.sql
|
||||
-- Make specific users ADMIN
|
||||
UPDATE users
|
||||
SET role = 'ADMIN', is_admin = true
|
||||
WHERE email IN ('admin@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}');
|
||||
WHERE email IN ('admin@royalenfield.com', 'it.admin@royalenfield.com');
|
||||
|
||||
-- Make specific users MANAGEMENT
|
||||
UPDATE users
|
||||
SET role = 'MANAGEMENT', is_admin = false
|
||||
WHERE email IN ('manager@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}');
|
||||
WHERE email IN ('manager@royalenfield.com', 'auditor@royalenfield.com');
|
||||
|
||||
-- Verify roles
|
||||
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
|
||||
@ -219,7 +219,7 @@ GROUP BY role;
|
||||
-- Check specific user
|
||||
SELECT email, role, is_admin
|
||||
FROM users
|
||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
WHERE email = 'your-email@royalenfield.com';
|
||||
```
|
||||
|
||||
### Test 2: Test API Access
|
||||
@ -356,7 +356,7 @@ WHERE designation ILIKE '%manager%' OR designation ILIKE '%head%';
|
||||
```sql
|
||||
SELECT email, role, is_admin
|
||||
FROM users
|
||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||
WHERE email = 'your-email@royalenfield.com';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -314,7 +314,7 @@ JWT_EXPIRY=24h
|
||||
REFRESH_TOKEN_EXPIRY=7d
|
||||
|
||||
# Okta Configuration
|
||||
OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
||||
OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||
OKTA_CLIENT_ID=your-client-id
|
||||
OKTA_CLIENT_SECRET=your-client-secret
|
||||
|
||||
@ -334,7 +334,7 @@ GCP_BUCKET_PUBLIC=true
|
||||
|
||||
**Identity Provider**: Okta
|
||||
- **Domain**: Configurable via `OKTA_DOMAIN` environment variable
|
||||
- **Default**: `https://{{IDP_DOMAIN}}`
|
||||
- **Default**: `https://dev-830839.oktapreview.com`
|
||||
- **Protocol**: OAuth 2.0 / OpenID Connect (OIDC)
|
||||
- **Grant Types**: Authorization Code, Resource Owner Password Credentials
|
||||
|
||||
@ -650,7 +650,7 @@ graph LR
|
||||
{
|
||||
"userId": "uuid",
|
||||
"employeeId": "EMP001",
|
||||
"email": "user@{{APP_DOMAIN}}",
|
||||
"email": "user@royalenfield.com",
|
||||
"role": "USER" | "MANAGEMENT" | "ADMIN",
|
||||
"iat": 1234567890,
|
||||
"exp": 1234654290
|
||||
@ -1048,7 +1048,7 @@ JWT_EXPIRY=24h
|
||||
REFRESH_TOKEN_EXPIRY=7d
|
||||
|
||||
# Okta
|
||||
OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
||||
OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||
OKTA_CLIENT_ID=your-client-id
|
||||
OKTA_CLIENT_SECRET=your-client-secret
|
||||
|
||||
@ -1063,7 +1063,7 @@ GCP_BUCKET_PUBLIC=true
|
||||
**Frontend (.env):**
|
||||
```env
|
||||
VITE_API_BASE_URL=https://api.rebridge.co.in/api/v1
|
||||
VITE_OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
||||
VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||
VITE_OKTA_CLIENT_ID=your-client-id
|
||||
```
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ await this.createClaimApprovalLevels(
|
||||
isAuto: false,
|
||||
approverType: 'department_lead' as const,
|
||||
approverId: departmentLead?.userId || null,
|
||||
approverEmail: departmentLead?.email || initiator.manager || `deptlead@${appDomain}`,
|
||||
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com',
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -181,7 +181,7 @@ POST http://localhost:5000/api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "john.doe@{{APP_DOMAIN}}",
|
||||
"username": "john.doe@royalenfield.com",
|
||||
"password": "SecurePassword123!"
|
||||
}
|
||||
```
|
||||
|
||||
20
env.example
20
env.example
@ -26,8 +26,8 @@ REFRESH_TOKEN_EXPIRY=7d
|
||||
SESSION_SECRET=your_session_secret_here_min_32_chars
|
||||
|
||||
# Cloud Storage (GCP)
|
||||
GCP_PROJECT_ID={{GCP_PROJECT_ID}}
|
||||
GCP_BUCKET_NAME={{GCP_BUCKET_NAME}}
|
||||
GCP_PROJECT_ID=re-workflow-project
|
||||
GCP_BUCKET_NAME=re-workflow-documents
|
||||
GCP_KEY_FILE=./config/gcp-key.json
|
||||
|
||||
# Google Secret Manager (Optional - for production)
|
||||
@ -41,9 +41,9 @@ USE_GOOGLE_SECRET_MANAGER=false
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||
SMTP_USER=notifications@royalenfield.com
|
||||
SMTP_PASSWORD=your_smtp_password
|
||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||
|
||||
# AI Service (for conclusion generation) - Vertex AI Gemini
|
||||
# Uses service account credentials from GCP_KEY_FILE
|
||||
@ -55,7 +55,7 @@ VERTEX_AI_LOCATION=asia-south1
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE_PATH=./logs
|
||||
APP_VERSION={{APP_VERSION}}
|
||||
APP_VERSION=1.2.0
|
||||
|
||||
# ============ Loki Configuration (Grafana Log Aggregation) ============
|
||||
LOKI_HOST= # e.g., http://loki:3100 or http://monitoring.cloudtopiaa.com:3100
|
||||
@ -66,7 +66,7 @@ LOKI_PASSWORD= # Optional: Basic auth password
|
||||
CORS_ORIGIN="*"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# File Upload
|
||||
@ -83,16 +83,16 @@ OKTA_CLIENT_ID={{okta_client_id}}
|
||||
OKTA_CLIENT_SECRET={{okta_client_secret}}
|
||||
|
||||
# Notificaton Service Worker credentials
|
||||
VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
|
||||
VAPID_PUBLIC_KEY={{vapid_public_key}} note: same key need to add on front end for web push
|
||||
VAPID_PRIVATE_KEY={{vapid_private_key}}
|
||||
VAPID_CONTACT=mailto:you@example.com
|
||||
|
||||
#Redis
|
||||
REDIS_URL={{REDIS_URL}}
|
||||
TAT_TEST_MODE=false # Set to true to accelerate TAT for testing
|
||||
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
|
||||
TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
|
||||
|
||||
# SAP Integration (OData Service via Zscaler)
|
||||
SAP_BASE_URL=https://{{SAP_DOMAIN_HERE}}:{{PORT}}
|
||||
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
|
||||
SAP_USERNAME={{SAP_USERNAME}}
|
||||
SAP_PASSWORD={{SAP_PASSWORD}}
|
||||
SAP_TIMEOUT_MS=30000
|
||||
|
||||
@ -52,8 +52,6 @@ scrape_configs:
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 10s
|
||||
scrape_timeout: 5s
|
||||
authorization:
|
||||
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
|
||||
|
||||
# ============================================
|
||||
# Node Exporter - Host Metrics
|
||||
|
||||
847
package-lock.json
generated
847
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
||||
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"start": "npm install && npm run build && npm run setup && npm run start:prod",
|
||||
"start": "npm run build && npm run start:prod && npm run setup",
|
||||
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"build": "tsc && tsc-alias",
|
||||
@ -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",
|
||||
@ -51,8 +50,6 @@
|
||||
"pg": "^8.13.1",
|
||||
"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 +72,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",
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
UPDATE users
|
||||
SET role = 'ADMIN'
|
||||
WHERE email = 'YOUR_EMAIL@{{APP_DOMAIN}}' -- ← CHANGE THIS
|
||||
WHERE email = 'YOUR_EMAIL@royalenfield.com' -- ← CHANGE THIS
|
||||
RETURNING
|
||||
user_id,
|
||||
email,
|
||||
|
||||
@ -21,9 +21,9 @@
|
||||
UPDATE users
|
||||
SET role = 'ADMIN'
|
||||
WHERE email IN (
|
||||
'admin@{{APP_DOMAIN}}',
|
||||
'it.admin@{{APP_DOMAIN}}',
|
||||
'system.admin@{{APP_DOMAIN}}'
|
||||
'admin@royalenfield.com',
|
||||
'it.admin@royalenfield.com',
|
||||
'system.admin@royalenfield.com'
|
||||
-- Add more admin emails here
|
||||
);
|
||||
|
||||
@ -45,9 +45,9 @@ ORDER BY email;
|
||||
UPDATE users
|
||||
SET role = 'MANAGEMENT'
|
||||
WHERE email IN (
|
||||
'manager1@{{APP_DOMAIN}}',
|
||||
'dept.head@{{APP_DOMAIN}}',
|
||||
'auditor@{{APP_DOMAIN}}'
|
||||
'manager1@royalenfield.com',
|
||||
'dept.head@royalenfield.com',
|
||||
'auditor@royalenfield.com'
|
||||
-- Add more management emails here
|
||||
);
|
||||
|
||||
|
||||
@ -162,7 +162,7 @@ SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=${SMTP_USER}
|
||||
SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||
|
||||
# Vertex AI Gemini Configuration (for conclusion generation)
|
||||
# Service account credentials should be placed in ./credentials/ folder
|
||||
@ -232,7 +232,7 @@ show_vapid_instructions() {
|
||||
echo " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
|
||||
echo ""
|
||||
echo "5. The VAPID_CONTACT should be a valid mailto: URL"
|
||||
echo " Example: mailto:admin@{{APP_DOMAIN}}"
|
||||
echo " Example: mailto:admin@royalenfield.com"
|
||||
echo ""
|
||||
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
|
||||
echo ""
|
||||
|
||||
48
src/app.ts
48
src/app.ts
@ -7,14 +7,10 @@ import { UserService } from './services/user.service';
|
||||
import { SSOUserData } from './types/auth.types';
|
||||
import { sequelize } from './config/database';
|
||||
import { corsMiddleware } from './middlewares/cors.middleware';
|
||||
import { authenticateToken } from './middlewares/auth.middleware';
|
||||
import { requireAdmin } from './middlewares/authorization.middleware';
|
||||
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
||||
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 +23,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
|
||||
@ -40,10 +36,8 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
|
||||
connectSrc.push(...origins);
|
||||
}
|
||||
|
||||
const apiDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||
|
||||
// Define strict CSP directives
|
||||
//: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
||||
// CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
||||
// even if the header is truncated in certain response types (like 301 redirects).
|
||||
const directives = [
|
||||
"frame-ancestors 'self'",
|
||||
@ -51,13 +45,13 @@ 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'",
|
||||
"script-src-attr 'none'",
|
||||
`img-src 'self' data: blob: https://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`,
|
||||
"img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com",
|
||||
"frame-src 'self' blob: data:",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"object-src 'none'",
|
||||
@ -91,17 +85,18 @@ app.use(cookieParser());
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
// Initializer for database connection (called from server.ts)
|
||||
export const initializeAppDatabase = async () => {
|
||||
// Initialize database connection
|
||||
const initializeDatabase = async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ App database connection established');
|
||||
} catch (error) {
|
||||
console.error('❌ App database connection failed:', error);
|
||||
throw error;
|
||||
console.error('❌ Database connection failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database
|
||||
initializeDatabase();
|
||||
|
||||
// Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.)
|
||||
// This allows Express to read X-Forwarded-* headers correctly
|
||||
// Set to true in production, false in development
|
||||
@ -116,12 +111,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'));
|
||||
|
||||
@ -129,7 +118,7 @@ app.use(morgan('combined'));
|
||||
app.use(metricsMiddleware);
|
||||
|
||||
// Prometheus metrics endpoint - expose metrics for scraping
|
||||
app.use('/metrics', authenticateToken, requireAdmin, createMetricsRouter());
|
||||
app.use(createMetricsRouter());
|
||||
|
||||
// Health check endpoint (before API routes)
|
||||
app.get('/health', (_req: express.Request, res: express.Response) => {
|
||||
@ -146,16 +135,7 @@ app.use('/api/v1', routes);
|
||||
|
||||
// Serve uploaded files statically
|
||||
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);
|
||||
}
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
// 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> => {
|
||||
@ -209,7 +189,7 @@ app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.
|
||||
});
|
||||
|
||||
// Get all users endpoint
|
||||
app.get('/api/v1/users', authenticateToken, requireAdmin, async (_req: express.Request, res: express.Response): Promise<void> => {
|
||||
app.get('/api/v1/users', async (_req: express.Request, res: express.Response): Promise<void> => {
|
||||
try {
|
||||
const users = await userService.getAllUsers();
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export const emailConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
from: process.env.EMAIL_FROM || `RE Workflow System <notifications@${process.env.APP_DOMAIN || 'royalenfield.com'}>`,
|
||||
from: process.env.EMAIL_FROM || 'RE Workflow System <notifications@royalenfield.com>',
|
||||
|
||||
// Email templates
|
||||
templates: {
|
||||
|
||||
@ -12,14 +12,14 @@ const ssoConfig: SSOConfig = {
|
||||
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||
},
|
||||
// Okta/Auth0 configuration for token exchange
|
||||
get oktaDomain() { return process.env.OKTA_DOMAIN || `{{IDP_DOMAIN}}`; },
|
||||
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; },
|
||||
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
||||
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
||||
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
|
||||
// Tanflow configuration for token exchange
|
||||
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || `{{IDP_DOMAIN}}/realms/RE`; },
|
||||
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; },
|
||||
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
||||
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || `{{TANFLOW_CLIENT_SECRET}}`; },
|
||||
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; },
|
||||
};
|
||||
|
||||
export { ssoConfig };
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,13 +132,10 @@ export class AuthController {
|
||||
|
||||
// Set new access token in cookie if using cookie-based auth
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
@ -151,7 +148,7 @@ export class AuthController {
|
||||
message: 'Token refreshed successfully'
|
||||
}, 'Token refreshed successfully');
|
||||
} else {
|
||||
// Dev: Include token for debugging
|
||||
// Development: Include token for debugging
|
||||
ResponseHandler.success(res, {
|
||||
accessToken: newAccessToken
|
||||
}, 'Token refreshed successfully');
|
||||
@ -209,13 +206,10 @@ export class AuthController {
|
||||
|
||||
// Set tokens in httpOnly cookies (production) or return in body (development)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? ('lax' as const) : ('lax' as const),
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
path: '/',
|
||||
};
|
||||
@ -262,13 +256,10 @@ export class AuthController {
|
||||
|
||||
// Set new access token in cookie
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? ('lax' as const) : ('lax' as const),
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/',
|
||||
};
|
||||
@ -302,16 +293,13 @@ export class AuthController {
|
||||
|
||||
// Helper function to clear cookies with all possible option combinations
|
||||
const clearCookiesCompletely = () => {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
const cookieNames = ['accessToken', 'refreshToken'];
|
||||
|
||||
// Get the EXACT options used when setting cookies (from exchangeToken)
|
||||
// These MUST match exactly: httpOnly, secure, sameSite, path
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
};
|
||||
@ -481,13 +469,10 @@ export class AuthController {
|
||||
|
||||
// Set cookies for web clients
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const,
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'lax' as const : 'lax' as const,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
@ -564,13 +549,10 @@ export class AuthController {
|
||||
|
||||
// Set cookies with httpOnly flag for security
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isUat = process.env.NODE_ENV === 'uat';
|
||||
const isSecureEnv = isProduction || isUat;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
|
||||
secure: isProduction,
|
||||
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
|
||||
};
|
||||
|
||||
@ -602,7 +584,7 @@ export class AuthController {
|
||||
idToken: result.oktaIdToken
|
||||
}, 'Token exchange successful');
|
||||
} else {
|
||||
// Dev: Include tokens for debugging and different-port setup
|
||||
// Development: Include tokens for debugging and different-port setup
|
||||
ResponseHandler.success(res, {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
|
||||
@ -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,66 +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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download E-Invoice PDF
|
||||
* GET /api/v1/dealer-claims/:requestId/e-invoice/pdf
|
||||
*/
|
||||
async downloadInvoicePdf(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
||||
|
||||
// Find workflow to get actual UUID
|
||||
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;
|
||||
if (!requestId) {
|
||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
||||
}
|
||||
|
||||
const { ClaimInvoice } = await import('../models/ClaimInvoice');
|
||||
let invoice = await ClaimInvoice.findOne({ where: { requestId } });
|
||||
|
||||
if (!invoice) {
|
||||
return ResponseHandler.error(res, 'Invoice record not found', 404);
|
||||
}
|
||||
|
||||
// Generate PDF on the fly
|
||||
try {
|
||||
const { pdfService } = await import('../services/pdf.service');
|
||||
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
|
||||
|
||||
const requestNumber = workflow.requestNumber || 'invoice';
|
||||
const fileName = `Invoice_${requestNumber}.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
|
||||
// Convert Buffer to stream
|
||||
const { Readable } = await import('stream');
|
||||
const stream = new Readable();
|
||||
stream.push(pdfBuffer);
|
||||
stream.push(null);
|
||||
stream.pipe(res);
|
||||
} catch (pdfError) {
|
||||
logger.error(`[DealerClaimController] Failed to generate PDF:`, pdfError);
|
||||
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[DealerClaimController] Error downloading invoice PDF:', error);
|
||||
return ResponseHandler.error(res, 'Failed to download invoice PDF', 500, errorMessage);
|
||||
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@ -988,98 +923,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { dealerExternalService } from '../services/dealerExternal.service';
|
||||
import { ResponseHandler } from '../utils/responseHandler';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class DealerExternalController {
|
||||
/**
|
||||
* Search dealer by code via external API
|
||||
* GET /api/v1/dealers-external/search/:dealerCode
|
||||
*/
|
||||
async searchByDealerCode(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { dealerCode } = req.params;
|
||||
|
||||
if (!dealerCode) {
|
||||
return ResponseHandler.error(res, 'Dealer code is required', 400);
|
||||
}
|
||||
|
||||
const dealerInfo = await dealerExternalService.getDealerByCode(dealerCode);
|
||||
|
||||
if (!dealerInfo) {
|
||||
return ResponseHandler.error(res, 'Dealer not found in external system', 404);
|
||||
}
|
||||
|
||||
return ResponseHandler.success(res, dealerInfo, 'Dealer found successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`[DealerExternalController] Error searching dealer ${req.params.dealerCode}:`, error);
|
||||
return ResponseHandler.error(res, 'Failed to fetch dealer from external source', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dealerExternalController = new DealerExternalController();
|
||||
@ -10,37 +10,13 @@ export class UserController {
|
||||
this.userService = new UserService();
|
||||
}
|
||||
|
||||
async getAllUsers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const users = await this.userService.getAllUsers();
|
||||
|
||||
const result = {
|
||||
users: users.map(u => ({
|
||||
userId: u.userId,
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
department: u.department,
|
||||
designation: u.designation,
|
||||
isActive: u.isActive,
|
||||
})),
|
||||
total: users.length
|
||||
};
|
||||
|
||||
ResponseHandler.success(res, result, 'All users fetched');
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch all users', { error });
|
||||
ResponseHandler.error(res, 'Failed to fetch all users', 500);
|
||||
}
|
||||
}
|
||||
|
||||
async searchUsers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const q = String(req.query.q || '').trim();
|
||||
const limit = Number(req.query.limit || 10);
|
||||
const source = String(req.query.source || 'default') as 'local' | 'okta' | 'default';
|
||||
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
|
||||
|
||||
const users = await this.userService.searchUsers(q, limit, currentUserId, source);
|
||||
const users = await this.userService.searchUsers(q, limit, currentUserId);
|
||||
|
||||
const result = users.map(u => ({
|
||||
userId: (u as any).userId,
|
||||
@ -93,31 +69,6 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const user = await this.userService.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
ResponseHandler.error(res, 'User not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
department: user.department,
|
||||
isActive: user.isActive
|
||||
}, 'User fetched');
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch user by ID', { error });
|
||||
ResponseHandler.error(res, 'Failed to fetch user by ID', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user exists in database (create if not exists)
|
||||
* Called when user is selected/tagged in the frontend
|
||||
|
||||
@ -12,7 +12,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { getRequestMetadata } from '@utils/requestUtils';
|
||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service';
|
||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
|
||||
import { DealerClaimService } from '@services/dealerClaim.service';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
@ -27,15 +27,6 @@ export class WorkflowController {
|
||||
// Validate initiator exists
|
||||
await validateInitiator(req.user.userId);
|
||||
|
||||
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
|
||||
const dealerCode = req.body.dealerCode || (req.body as any).dealer_code;
|
||||
if (dealerCode || validatedData.templateType === 'DEALER CLAIM') {
|
||||
if (!dealerCode) {
|
||||
throw new Error('Dealer code is required for dealer claim requests');
|
||||
}
|
||||
await validateDealerUser(dealerCode);
|
||||
}
|
||||
|
||||
// Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
|
||||
let approvalLevels = validatedData.approvalLevels || [];
|
||||
if (!approvalLevels.length && (req.body as any).approvers) {
|
||||
@ -179,15 +170,6 @@ export class WorkflowController {
|
||||
// Validate initiator exists
|
||||
await validateInitiator(userId);
|
||||
|
||||
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
|
||||
const dealerCode = parsed.dealerCode || parsed.dealer_code;
|
||||
if (dealerCode || validated.templateType === 'DEALER CLAIM') {
|
||||
if (!dealerCode) {
|
||||
throw new Error('Dealer code is required for dealer claim requests');
|
||||
}
|
||||
await validateDealerUser(dealerCode);
|
||||
}
|
||||
|
||||
// Use the approval levels from validation (already transformed above)
|
||||
let approvalLevels = validated.approvalLevels || [];
|
||||
|
||||
|
||||
@ -172,7 +172,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
||||
- Initiator (for record)
|
||||
- Finance team
|
||||
- **Template**: `creditNoteSent.template.ts` (NEW)
|
||||
- **Status**: Required implementation
|
||||
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
|
||||
- **Notification Type**: `credit_note_sent`
|
||||
- **Data Needed**:
|
||||
- Credit note number
|
||||
@ -184,7 +184,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
||||
- Reason for credit note
|
||||
- Download link (if available)
|
||||
- **Notes**:
|
||||
- Planned for email implementation
|
||||
- Currently has TODO comment for email implementation
|
||||
- Critical for dealer notification
|
||||
|
||||
---
|
||||
@ -246,7 +246,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
||||
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
|
||||
- Priority: High
|
||||
- When: Credit note is sent to dealer (Step 8)
|
||||
- Planned for implementation
|
||||
- Currently has TODO comment
|
||||
|
||||
---
|
||||
|
||||
@ -255,7 +255,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
||||
### High Priority (Critical for Workflow)
|
||||
1. **Activity Created** - Currently using generic notification, should be branded
|
||||
2. **E-Invoice Generated** - Important for financial tracking
|
||||
3. **Credit Note Sent** - Critical for dealer notification
|
||||
3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
|
||||
|
||||
### Medium Priority (Nice to Have)
|
||||
4. **Proposal Submitted** - Better UX, but existing approval request works
|
||||
|
||||
@ -991,9 +991,9 @@ Add to `.env`:
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||
SMTP_USER=notifications@royalenfield.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
|
||||
EMAIL_FROM=RE Flow <noreply@royalenfield.com>
|
||||
|
||||
# Email Settings
|
||||
EMAIL_ENABLED=true
|
||||
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
|
||||
EMAIL_RETRY_ATTEMPTS=3
|
||||
|
||||
# Application
|
||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||
BASE_URL=https://workflow.royalenfield.com
|
||||
COMPANY_NAME=Royal Enfield
|
||||
COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
|
||||
SUPPORT_EMAIL=support@{{APP_DOMAIN}}
|
||||
COMPANY_WEBSITE=https://www.royalenfield.com
|
||||
SUPPORT_EMAIL=support@royalenfield.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
|
||||
All templates feature a single action button:
|
||||
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
||||
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
||||
- **Example:** `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
||||
- **Example:** `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||
|
||||
No approval/rejection buttons in emails - all actions happen within the application.
|
||||
|
||||
@ -231,8 +231,8 @@ SMTP_USER=your-email@domain.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
|
||||
# Email Settings
|
||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||
BASE_URL=https://workflow.royalenfield.com
|
||||
COMPANY_NAME=Royal Enfield
|
||||
```
|
||||
|
||||
|
||||
@ -361,7 +361,7 @@ All `[ViewDetailsLink]` placeholders should be replaced with:
|
||||
{baseURL}/request/{requestNumber}
|
||||
```
|
||||
|
||||
Example: `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
||||
Example: `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||
|
||||
### Company Name
|
||||
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
||||
|
||||
@ -12,15 +12,15 @@ emailtemplates/
|
||||
├── approvalRequest.template.ts ✅ Single approver email
|
||||
├── multiApproverRequest.template.ts ✅ Multi-approver email
|
||||
│
|
||||
├── approvalConfirmation.template.ts ✅ DONE
|
||||
├── rejectionNotification.template.ts ✅ DONE
|
||||
├── tatReminder.template.ts ✅ DONE
|
||||
├── tatBreached.template.ts ✅ DONE
|
||||
├── workflowPaused.template.ts ✅ DONE
|
||||
├── workflowResumed.template.ts ✅ DONE
|
||||
├── participantAdded.template.ts ✅ DONE
|
||||
├── approverSkipped.template.ts ✅ DONE
|
||||
└── requestClosed.template.ts ✅ DONE
|
||||
├── approvalConfirmation.template.ts 🔨 TODO
|
||||
├── rejectionNotification.template.ts 🔨 TODO
|
||||
├── tatReminder.template.ts 🔨 TODO
|
||||
├── tatBreached.template.ts 🔨 TODO
|
||||
├── workflowPaused.template.ts 🔨 TODO
|
||||
├── workflowResumed.template.ts 🔨 TODO
|
||||
├── participantAdded.template.ts 🔨 TODO
|
||||
├── approverSkipped.template.ts 🔨 TODO
|
||||
└── requestClosed.template.ts 🔨 TODO
|
||||
```
|
||||
|
||||
---
|
||||
@ -53,7 +53,7 @@ const data: RequestCreatedData = {
|
||||
requestTime: '02:30 PM',
|
||||
totalApprovers: 3,
|
||||
expectedTAT: 48,
|
||||
viewDetailsLink: 'https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013',
|
||||
viewDetailsLink: 'https://workflow.royalenfield.com/request/REQ-2025-12-0013',
|
||||
companyName: 'Royal Enfield'
|
||||
};
|
||||
```
|
||||
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
|
||||
# Email Settings
|
||||
EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
|
||||
EMAIL_FROM=Royal Enfield Workflow <notifications@royalenfield.com>
|
||||
|
||||
# Application Settings
|
||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||
BASE_URL=https://workflow.royalenfield.com
|
||||
COMPANY_NAME=Royal Enfield
|
||||
```
|
||||
|
||||
|
||||
@ -7,20 +7,18 @@
|
||||
|
||||
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
||||
|
||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||
|
||||
/**
|
||||
* Company Information
|
||||
*/
|
||||
export const CompanyInfo = {
|
||||
name: 'Royal Enfield',
|
||||
productName: 'RE Flow', // Product name displayed in header
|
||||
website: `https://www.${appDomain}`,
|
||||
supportEmail: `support@${appDomain}`,
|
||||
website: 'https://www.royalenfield.com',
|
||||
supportEmail: 'support@royalenfield.com',
|
||||
|
||||
// Logo configuration for email headers
|
||||
logo: {
|
||||
url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`,
|
||||
url: 'https://www.royalenfield.com/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp',
|
||||
alt: 'Royal Enfield Logo',
|
||||
width: 220, // Logo width in pixels (wider for better visibility)
|
||||
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
||||
|
||||
@ -270,7 +270,7 @@ export async function shouldSendEmailWithOverride(
|
||||
userId: string,
|
||||
emailType: EmailNotificationType
|
||||
): Promise<boolean> {
|
||||
// emails always sent (override user preference)
|
||||
// Critical emails always sent (override user preference)
|
||||
if (CRITICAL_EMAILS.includes(emailType)) {
|
||||
const adminEnabled = await isAdminEmailEnabled(emailType);
|
||||
if (adminEnabled) {
|
||||
|
||||
@ -8,8 +8,6 @@
|
||||
import { emailNotificationService } from '../services/emailNotification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||
|
||||
/**
|
||||
* Simulate real workflow scenario
|
||||
*/
|
||||
@ -20,7 +18,7 @@ async function testRealScenario() {
|
||||
// Mock user data (simulating real database records)
|
||||
const user10 = {
|
||||
userId: 'user-10-uuid',
|
||||
email: `john.doe@${appDomain}`,
|
||||
email: 'john.doe@royalenfield.com',
|
||||
displayName: 'John Doe',
|
||||
department: 'Engineering',
|
||||
designation: 'Senior Engineer'
|
||||
@ -28,7 +26,7 @@ async function testRealScenario() {
|
||||
|
||||
const user12 = {
|
||||
userId: 'user-12-uuid',
|
||||
email: `jane.smith@${appDomain}`,
|
||||
email: 'jane.smith@royalenfield.com',
|
||||
displayName: 'Jane Smith',
|
||||
department: 'Management',
|
||||
designation: 'Engineering Manager',
|
||||
@ -54,7 +52,7 @@ async function testRealScenario() {
|
||||
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
||||
</blockquote>
|
||||
<p>Please review and approve at your earliest convenience.</p>
|
||||
<p>For questions, contact: <a href="mailto:john.doe@${appDomain}">john.doe@${appDomain}</a></p>
|
||||
<p>For questions, contact: <a href="mailto:john.doe@royalenfield.com">john.doe@royalenfield.com</a></p>
|
||||
`,
|
||||
requestType: 'Purchase',
|
||||
priority: 'HIGH',
|
||||
@ -70,21 +68,21 @@ async function testRealScenario() {
|
||||
{
|
||||
levelNumber: 1,
|
||||
approverName: 'Jane Smith',
|
||||
approverEmail: `jane.smith@${appDomain}`,
|
||||
approverEmail: 'jane.smith@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
},
|
||||
{
|
||||
levelNumber: 2,
|
||||
approverName: 'Michael Brown',
|
||||
approverEmail: `michael.brown@${appDomain}`,
|
||||
approverEmail: 'michael.brown@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
},
|
||||
{
|
||||
levelNumber: 3,
|
||||
approverName: 'Sarah Johnson',
|
||||
approverEmail: `sarah.johnson@${appDomain}`,
|
||||
approverEmail: 'sarah.johnson@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
}
|
||||
@ -170,7 +168,7 @@ async function testRealScenario() {
|
||||
approvedUser12,
|
||||
user10,
|
||||
false, // not final approval
|
||||
{ displayName: 'Michael Brown', email: `michael.brown@${appDomain}` }
|
||||
{ displayName: 'Michael Brown', email: 'michael.brown@royalenfield.com' }
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
@ -254,3 +252,4 @@ testRealScenario()
|
||||
console.error('❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -15,7 +15,7 @@ const getAllowedOrigins = (): string[] | boolean => {
|
||||
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
||||
return [];
|
||||
} else {
|
||||
// Dev fallback: allow localhost:3000
|
||||
// Development fallback: allow localhost:3000
|
||||
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
||||
console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
|
||||
return ['http://localhost:3000'];
|
||||
|
||||
@ -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 async function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): Promise<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
|
||||
await 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 async function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): Promise<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)) {
|
||||
await 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();
|
||||
}
|
||||
|
||||
await 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 = await 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 = await 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,119 +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> {
|
||||
// 1. ActivityType
|
||||
const activityCols = {
|
||||
hsn_code: { type: DataTypes.STRING(20), allowNull: true },
|
||||
sac_code: { type: DataTypes.STRING(20), allowNull: true },
|
||||
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
gl_code: { type: DataTypes.STRING(20), allowNull: true },
|
||||
credit_nature: { type: DataTypes.STRING(50), allowNull: true }
|
||||
};
|
||||
|
||||
for (const [col, spec] of Object.entries(activityCols)) {
|
||||
if (!(await columnExists(queryInterface, 'activity_types', col))) {
|
||||
await queryInterface.addColumn('activity_types', col, spec);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. GST Fields mapping for Multiple Tables
|
||||
const gstFields = {
|
||||
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
gst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
cgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
cgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
sgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
sgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
igst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
igst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
utgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
utgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
cess_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
||||
cess_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
total_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
};
|
||||
|
||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses', 'claim_credit_notes'];
|
||||
|
||||
for (const table of tables) {
|
||||
for (const [col, spec] of Object.entries(gstFields)) {
|
||||
if (!(await columnExists(queryInterface, table, col))) {
|
||||
await queryInterface.addColumn(table, col, spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing expense_date to DealerCompletionExpense
|
||||
if (!(await columnExists(queryInterface, 'dealer_completion_expenses', 'expense_date'))) {
|
||||
await queryInterface.addColumn('dealer_completion_expenses', 'expense_date', { type: DataTypes.DATEONLY, allowNull: true });
|
||||
}
|
||||
|
||||
// 3. ClaimInvoice
|
||||
const invoiceCols = {
|
||||
irn: { type: DataTypes.STRING(500), allowNull: true },
|
||||
ack_no: { type: DataTypes.STRING(255), allowNull: true },
|
||||
ack_date: { type: DataTypes.DATE, allowNull: true },
|
||||
signed_invoice: { type: DataTypes.TEXT, allowNull: true },
|
||||
signed_invoice_url: { type: DataTypes.STRING(500), allowNull: true },
|
||||
dealer_claim_number: { type: DataTypes.STRING(100), allowNull: true },
|
||||
qr_code: { type: DataTypes.TEXT, allowNull: true },
|
||||
qr_image: { type: DataTypes.TEXT, allowNull: true },
|
||||
dealer_claim_date: { type: DataTypes.DATEONLY, allowNull: true },
|
||||
billing_no: { type: DataTypes.STRING(100), allowNull: true },
|
||||
billing_date: { type: DataTypes.DATEONLY, allowNull: true },
|
||||
taxable_value: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
cgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
sgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
igst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
utgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
cess_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
tcs_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
round_off_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
||||
place_of_supply: { type: DataTypes.STRING(255), allowNull: true },
|
||||
total_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
|
||||
tax_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
|
||||
credit_nature: { type: DataTypes.STRING(100), allowNull: true },
|
||||
consignor_gsin: { type: DataTypes.STRING(255), allowNull: true },
|
||||
gstin_date: { type: DataTypes.DATEONLY, allowNull: true }
|
||||
};
|
||||
|
||||
for (const [col, spec] of Object.entries(invoiceCols)) {
|
||||
if (!(await columnExists(queryInterface, 'claim_invoices', col))) {
|
||||
await queryInterface.addColumn('claim_invoices', col, spec);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure file_path exists as 'file_path'
|
||||
try {
|
||||
if (!(await columnExists(queryInterface, 'claim_invoices', 'file_path'))) {
|
||||
if (await columnExists(queryInterface, 'claim_invoices', 'invoice_file_path')) {
|
||||
await queryInterface.renameColumn('claim_invoices', 'invoice_file_path', 'file_path');
|
||||
} else {
|
||||
await queryInterface.addColumn('claim_invoices', 'file_path', { type: DataTypes.STRING(500), allowNull: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently continue
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Note: Best effort rollback (usually not recommended to drop columns in shared dev unless necessary)
|
||||
await queryInterface.removeColumn('dealer_completion_expenses', 'expense_date').catch(() => { });
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.addColumn('claim_invoices', 'pwc_response', {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
});
|
||||
await queryInterface.addColumn('claim_invoices', 'irp_response', {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.removeColumn('claim_invoices', 'pwc_response');
|
||||
await queryInterface.removeColumn('claim_invoices', 'irp_response');
|
||||
}
|
||||
@ -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,57 +0,0 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
// 1. Add total_proposed_taxable_amount to dealer_claim_details
|
||||
const dealerClaimDetailsTable = await queryInterface.describeTable('dealer_claim_details');
|
||||
if (!dealerClaimDetailsTable.total_proposed_taxable_amount) {
|
||||
await queryInterface.addColumn('dealer_claim_details', 'total_proposed_taxable_amount', {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Total taxable amount from proposal or actuals if higher'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Add taxable_closed_expenses to claim_budget_tracking
|
||||
const claimBudgetTrackingTable = await queryInterface.describeTable('claim_budget_tracking');
|
||||
if (!claimBudgetTrackingTable.taxable_closed_expenses) {
|
||||
await queryInterface.addColumn('claim_budget_tracking', 'taxable_closed_expenses', {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
comment: 'Total taxable amount from the completion expenses'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Remove unique constraint from internal_orders to support multiple IOs
|
||||
try {
|
||||
// Check if the unique index exists before trying to remove it
|
||||
const indexes = await queryInterface.showIndex('internal_orders');
|
||||
const uniqueIndex = (indexes as any[]).find((idx: any) => idx.name === 'idx_internal_orders_request_id_unique' || (idx.fields && idx.fields[0] && idx.fields[0].attribute === 'request_id' && idx.unique));
|
||||
|
||||
if (uniqueIndex) {
|
||||
await queryInterface.removeIndex('internal_orders', uniqueIndex.name);
|
||||
|
||||
// Add a non-unique index for performance
|
||||
await queryInterface.addIndex('internal_orders', ['request_id'], {
|
||||
name: 'idx_internal_orders_request_id'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing unique index from internal_orders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
// Rollback logic
|
||||
try {
|
||||
await queryInterface.removeColumn('dealer_claim_details', 'total_proposed_taxable_amount');
|
||||
await queryInterface.removeColumn('claim_budget_tracking', 'taxable_closed_expenses');
|
||||
|
||||
await queryInterface.removeIndex('internal_orders', 'idx_internal_orders_request_id');
|
||||
await queryInterface.addIndex('internal_orders', ['request_id'], {
|
||||
name: 'idx_internal_orders_request_id_unique',
|
||||
unique: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in migration rollback:', error);
|
||||
}
|
||||
}
|
||||
@ -9,18 +9,13 @@ interface ActivityTypeAttributes {
|
||||
taxationType?: string;
|
||||
sapRefNo?: string;
|
||||
isActive: boolean;
|
||||
hsnCode?: string | null;
|
||||
sacCode?: string | null;
|
||||
gstRate?: number | null;
|
||||
glCode?: string | null;
|
||||
creditNature?: 'Commercial' | 'GST' | null;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
|
||||
public activityTypeId!: string;
|
||||
@ -29,11 +24,6 @@ class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAtt
|
||||
public taxationType?: string;
|
||||
public sapRefNo?: string;
|
||||
public isActive!: boolean;
|
||||
public hsnCode?: string | null;
|
||||
public sacCode?: string | null;
|
||||
public gstRate?: number | null;
|
||||
public glCode?: string | null;
|
||||
public creditNature?: 'Commercial' | 'GST' | null;
|
||||
public createdBy!: string;
|
||||
public updatedBy?: string;
|
||||
public createdAt!: Date;
|
||||
@ -81,31 +71,6 @@ ActivityType.init(
|
||||
defaultValue: true,
|
||||
field: 'is_active'
|
||||
},
|
||||
hsnCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'hsn_code'
|
||||
},
|
||||
sacCode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'sac_code'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
},
|
||||
glCode: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
field: 'gl_code'
|
||||
},
|
||||
creditNature: {
|
||||
type: DataTypes.ENUM('Commercial', 'GST'),
|
||||
allowNull: true,
|
||||
field: 'credit_nature'
|
||||
},
|
||||
createdBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
|
||||
@ -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 };
|
||||
@ -29,7 +29,6 @@ interface ClaimBudgetTrackingAttributes {
|
||||
ioBlockedAt?: Date;
|
||||
// Closed Expenses
|
||||
closedExpenses?: number;
|
||||
taxableClosedExpenses?: number;
|
||||
closedExpensesSubmittedAt?: Date;
|
||||
// Final Claim Amount
|
||||
finalClaimAmount?: number;
|
||||
@ -51,7 +50,7 @@ interface ClaimBudgetTrackingAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'taxableClosedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
|
||||
public budgetId!: string;
|
||||
@ -65,7 +64,6 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
|
||||
public ioBlockedAmount?: number;
|
||||
public ioBlockedAt?: Date;
|
||||
public closedExpenses?: number;
|
||||
public taxableClosedExpenses?: number;
|
||||
public closedExpensesSubmittedAt?: Date;
|
||||
public finalClaimAmount?: number;
|
||||
public finalClaimAmountApprovedAt?: Date;
|
||||
@ -161,11 +159,6 @@ ClaimBudgetTracking.init(
|
||||
allowNull: true,
|
||||
field: 'closed_expenses_submitted_at'
|
||||
},
|
||||
taxableClosedExpenses: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'taxable_closed_expenses'
|
||||
},
|
||||
finalClaimAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
|
||||
@ -9,20 +9,7 @@ interface ClaimCreditNoteAttributes {
|
||||
invoiceId?: string;
|
||||
creditNoteNumber?: string;
|
||||
creditNoteDate?: Date;
|
||||
creditNoteAmount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
sgstAmt?: number;
|
||||
igstRate?: number;
|
||||
igstAmt?: number;
|
||||
utgstRate?: number;
|
||||
utgstAmt?: number;
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
creditNoteAmount?: number;
|
||||
sapDocumentNumber?: string;
|
||||
creditNoteFilePath?: string;
|
||||
status?: string;
|
||||
@ -35,7 +22,7 @@ interface ClaimCreditNoteAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
|
||||
public creditNoteId!: string;
|
||||
@ -43,20 +30,7 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
|
||||
public invoiceId?: string;
|
||||
public creditNoteNumber?: string;
|
||||
public creditNoteDate?: Date;
|
||||
public creditNoteAmount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
public sgstAmt?: number;
|
||||
public igstRate?: number;
|
||||
public igstAmt?: number;
|
||||
public utgstRate?: number;
|
||||
public utgstAmt?: number;
|
||||
public cessRate?: number;
|
||||
public cessAmt?: number;
|
||||
public totalAmt?: number;
|
||||
public creditNoteAmount?: number;
|
||||
public sapDocumentNumber?: string;
|
||||
public creditNoteFilePath?: string;
|
||||
public status?: string;
|
||||
@ -112,73 +86,8 @@ ClaimCreditNote.init(
|
||||
},
|
||||
creditNoteAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
field: 'credit_amount'
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
},
|
||||
gstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_amt'
|
||||
},
|
||||
cgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_rate'
|
||||
},
|
||||
cgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_amt'
|
||||
},
|
||||
sgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_rate'
|
||||
},
|
||||
sgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_amt'
|
||||
},
|
||||
igstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_rate'
|
||||
},
|
||||
igstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_amt'
|
||||
},
|
||||
utgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_rate'
|
||||
},
|
||||
utgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_amt'
|
||||
},
|
||||
cessRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_rate'
|
||||
},
|
||||
cessAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_amt'
|
||||
},
|
||||
totalAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
field: 'credit_amount',
|
||||
},
|
||||
sapDocumentNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
|
||||
@ -6,38 +6,11 @@ interface ClaimInvoiceAttributes {
|
||||
invoiceId: string;
|
||||
requestId: string;
|
||||
invoiceNumber?: string;
|
||||
dmsNumber?: string;
|
||||
invoiceDate?: Date;
|
||||
amount: number;
|
||||
status: string;
|
||||
irn?: string | null;
|
||||
ackNo?: string | null;
|
||||
ackDate?: Date | null;
|
||||
signedInvoice?: string | null;
|
||||
signedInvoiceUrl?: string | null;
|
||||
dealerClaimNumber?: string | null;
|
||||
dealerClaimDate?: Date | null;
|
||||
billingNo?: string | null;
|
||||
billingDate?: Date | null;
|
||||
taxableValue?: number | null;
|
||||
cgstTotal?: number | null;
|
||||
sgstTotal?: number | null;
|
||||
igstTotal?: number | null;
|
||||
utgstTotal?: number | null;
|
||||
cessTotal?: number | null;
|
||||
tcsAmt?: number | null;
|
||||
roundOffAmt?: number | null;
|
||||
placeOfSupply?: string | null;
|
||||
totalValueInWords?: string | null;
|
||||
taxValueInWords?: string | null;
|
||||
creditNature?: string | null;
|
||||
consignorGsin?: string | null;
|
||||
gstinDate?: Date | null;
|
||||
filePath?: string | null;
|
||||
qrCode?: string | null;
|
||||
qrImage?: string | null;
|
||||
pwcResponse?: any;
|
||||
irpResponse?: any;
|
||||
amount?: number;
|
||||
dmsNumber?: string;
|
||||
invoiceFilePath?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
generatedAt?: Date;
|
||||
description?: string;
|
||||
@ -45,44 +18,17 @@ interface ClaimInvoiceAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'dmsNumber' | 'invoiceDate' | 'irn' | 'ackNo' | 'ackDate' | 'signedInvoice' | 'signedInvoiceUrl' | 'dealerClaimNumber' | 'dealerClaimDate' | 'billingNo' | 'billingDate' | 'taxableValue' | 'cgstTotal' | 'sgstTotal' | 'igstTotal' | 'utgstTotal' | 'cessTotal' | 'tcsAmt' | 'roundOffAmt' | 'placeOfSupply' | 'totalValueInWords' | 'taxValueInWords' | 'creditNature' | 'consignorGsin' | 'gstinDate' | 'filePath' | 'qrCode' | 'qrImage' | 'pwcResponse' | 'irpResponse' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> { }
|
||||
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
|
||||
public invoiceId!: string;
|
||||
public requestId!: string;
|
||||
public invoiceNumber?: string;
|
||||
public dmsNumber?: string;
|
||||
public invoiceDate?: Date;
|
||||
public amount!: number;
|
||||
public status!: string;
|
||||
public irn?: string | null;
|
||||
public ackNo?: string | null;
|
||||
public ackDate?: Date | null;
|
||||
public signedInvoice?: string | null;
|
||||
public signedInvoiceUrl?: string | null;
|
||||
public dealerClaimNumber?: string | null;
|
||||
public dealerClaimDate?: Date | null;
|
||||
public billingNo?: string | null;
|
||||
public billingDate?: Date | null;
|
||||
public taxableValue?: number | null;
|
||||
public cgstTotal?: number | null;
|
||||
public sgstTotal?: number | null;
|
||||
public igstTotal?: number | null;
|
||||
public utgstTotal?: number | null;
|
||||
public cessTotal?: number | null;
|
||||
public tcsAmt?: number | null;
|
||||
public roundOffAmt?: number | null;
|
||||
public placeOfSupply?: string | null;
|
||||
public totalValueInWords?: string | null;
|
||||
public taxValueInWords?: string | null;
|
||||
public creditNature?: string | null;
|
||||
public consignorGsin?: string | null;
|
||||
public gstinDate?: Date | null;
|
||||
public filePath?: string | null;
|
||||
public qrCode?: string | null;
|
||||
public qrImage?: string | null;
|
||||
public pwcResponse?: any;
|
||||
public irpResponse?: any;
|
||||
public amount?: number;
|
||||
public dmsNumber?: string;
|
||||
public invoiceFilePath?: string;
|
||||
public status?: string;
|
||||
public errorMessage?: string;
|
||||
public generatedAt?: Date;
|
||||
public description?: string;
|
||||
@ -115,11 +61,6 @@ ClaimInvoice.init(
|
||||
allowNull: true,
|
||||
field: 'invoice_number',
|
||||
},
|
||||
dmsNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'dms_number',
|
||||
},
|
||||
invoiceDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
@ -127,153 +68,23 @@ ClaimInvoice.init(
|
||||
},
|
||||
amount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
field: 'invoice_amount',
|
||||
},
|
||||
dmsNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'dms_number',
|
||||
},
|
||||
invoiceFilePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'invoice_file_path',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'PENDING',
|
||||
field: 'generation_status'
|
||||
},
|
||||
irn: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true
|
||||
},
|
||||
ackNo: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'ack_no'
|
||||
},
|
||||
ackDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'ack_date'
|
||||
},
|
||||
signedInvoice: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'signed_invoice'
|
||||
},
|
||||
signedInvoiceUrl: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'signed_invoice_url'
|
||||
},
|
||||
dealerClaimNumber: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'dealer_claim_number'
|
||||
},
|
||||
dealerClaimDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'dealer_claim_date'
|
||||
},
|
||||
billingNo: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'billing_no'
|
||||
},
|
||||
billingDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'billing_date'
|
||||
},
|
||||
taxableValue: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'taxable_value'
|
||||
},
|
||||
cgstTotal: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_total'
|
||||
},
|
||||
sgstTotal: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_total'
|
||||
},
|
||||
igstTotal: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_total'
|
||||
},
|
||||
utgstTotal: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_total'
|
||||
},
|
||||
cessTotal: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_total'
|
||||
},
|
||||
tcsAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'tcs_amt'
|
||||
},
|
||||
roundOffAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'round_off_amt'
|
||||
},
|
||||
placeOfSupply: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'place_of_supply'
|
||||
},
|
||||
totalValueInWords: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'total_value_in_words'
|
||||
},
|
||||
taxValueInWords: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'tax_value_in_words'
|
||||
},
|
||||
creditNature: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'credit_nature'
|
||||
},
|
||||
consignorGsin: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'consignor_gsin'
|
||||
},
|
||||
gstinDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'gstin_date'
|
||||
},
|
||||
filePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'file_path'
|
||||
},
|
||||
qrCode: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'qr_code'
|
||||
},
|
||||
qrImage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'qr_image'
|
||||
},
|
||||
pwcResponse: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'pwc_response'
|
||||
},
|
||||
irpResponse: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'irp_response'
|
||||
field: 'generation_status',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
@ -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,17 +11,16 @@ interface DealerClaimDetailsAttributes {
|
||||
dealerName: string;
|
||||
dealerEmail?: string;
|
||||
dealerPhone?: string;
|
||||
dealerAddress?: string | null;
|
||||
activityDate?: Date | null;
|
||||
dealerAddress?: string;
|
||||
activityDate?: Date;
|
||||
location?: string;
|
||||
periodStartDate?: Date;
|
||||
periodEndDate?: Date;
|
||||
totalProposedTaxableAmount?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { }
|
||||
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
||||
public claimId!: string;
|
||||
@ -32,12 +31,11 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
|
||||
public dealerName!: string;
|
||||
public dealerEmail?: string;
|
||||
public dealerPhone?: string;
|
||||
public dealerAddress?: string | null;
|
||||
public activityDate?: Date | null;
|
||||
public dealerAddress?: string;
|
||||
public activityDate?: Date;
|
||||
public location?: string;
|
||||
public periodStartDate?: Date;
|
||||
public periodEndDate?: Date;
|
||||
public totalProposedTaxableAmount?: number;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
@ -95,8 +93,8 @@ DealerClaimDetails.init(
|
||||
},
|
||||
dealerAddress: {
|
||||
type: DataTypes.TEXT,
|
||||
field: 'dealer_address',
|
||||
comment: 'Dealer address'
|
||||
allowNull: true,
|
||||
field: 'dealer_address'
|
||||
},
|
||||
activityDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
@ -117,12 +115,6 @@ DealerClaimDetails.init(
|
||||
allowNull: true,
|
||||
field: 'period_end_date'
|
||||
},
|
||||
totalProposedTaxableAmount: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_proposed_taxable_amount',
|
||||
comment: 'Total taxable amount from proposal or actuals if higher'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@ -9,28 +9,11 @@ interface DealerCompletionExpenseAttributes {
|
||||
completionId?: string | null;
|
||||
description: string;
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
sgstAmt?: number;
|
||||
igstRate?: number;
|
||||
igstAmt?: number;
|
||||
utgstRate?: number;
|
||||
utgstAmt?: number;
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
isService?: boolean;
|
||||
expenseDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> { }
|
||||
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
|
||||
public expenseId!: string;
|
||||
@ -38,23 +21,6 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
|
||||
public completionId?: string | null;
|
||||
public description!: string;
|
||||
public amount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public quantity?: number;
|
||||
public hsnCode?: string;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
public sgstAmt?: number;
|
||||
public igstRate?: number;
|
||||
public igstAmt?: number;
|
||||
public utgstRate?: number;
|
||||
public utgstAmt?: number;
|
||||
public cessRate?: number;
|
||||
public cessAmt?: number;
|
||||
public totalAmt?: number;
|
||||
public isService?: boolean;
|
||||
public expenseDate!: Date;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
}
|
||||
@ -97,93 +63,6 @@ DealerCompletionExpense.init(
|
||||
allowNull: false,
|
||||
field: 'amount',
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
},
|
||||
gstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
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,
|
||||
field: 'cgst_rate'
|
||||
},
|
||||
cgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_amt'
|
||||
},
|
||||
sgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_rate'
|
||||
},
|
||||
sgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_amt'
|
||||
},
|
||||
igstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_rate'
|
||||
},
|
||||
igstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_amt'
|
||||
},
|
||||
utgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_rate'
|
||||
},
|
||||
utgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_amt'
|
||||
},
|
||||
cessRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_rate'
|
||||
},
|
||||
cessAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_amt'
|
||||
},
|
||||
totalAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
},
|
||||
isService: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false,
|
||||
field: 'is_service'
|
||||
},
|
||||
expenseDate: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'expense_date',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@ -9,28 +9,12 @@ interface DealerProposalCostItemAttributes {
|
||||
requestId: string;
|
||||
itemDescription: string;
|
||||
amount: number;
|
||||
gstRate?: number;
|
||||
gstAmt?: number;
|
||||
quantity?: number;
|
||||
hsnCode?: string;
|
||||
cgstRate?: number;
|
||||
cgstAmt?: number;
|
||||
sgstRate?: number;
|
||||
sgstAmt?: number;
|
||||
igstRate?: number;
|
||||
igstAmt?: number;
|
||||
utgstRate?: number;
|
||||
utgstAmt?: number;
|
||||
cessRate?: number;
|
||||
cessAmt?: number;
|
||||
totalAmt?: number;
|
||||
isService?: boolean;
|
||||
itemOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> { }
|
||||
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
|
||||
public costItemId!: string;
|
||||
@ -38,22 +22,6 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
|
||||
public requestId!: string;
|
||||
public itemDescription!: string;
|
||||
public amount!: number;
|
||||
public gstRate?: number;
|
||||
public gstAmt?: number;
|
||||
public quantity?: number;
|
||||
public hsnCode?: string;
|
||||
public cgstRate?: number;
|
||||
public cgstAmt?: number;
|
||||
public sgstRate?: number;
|
||||
public sgstAmt?: number;
|
||||
public igstRate?: number;
|
||||
public igstAmt?: number;
|
||||
public utgstRate?: number;
|
||||
public utgstAmt?: number;
|
||||
public cessRate?: number;
|
||||
public cessAmt?: number;
|
||||
public totalAmt?: number;
|
||||
public isService?: boolean;
|
||||
public itemOrder!: number;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
@ -98,88 +66,6 @@ DealerProposalCostItem.init(
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: false
|
||||
},
|
||||
gstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'gst_rate'
|
||||
},
|
||||
gstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
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,
|
||||
field: 'cgst_rate'
|
||||
},
|
||||
cgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cgst_amt'
|
||||
},
|
||||
sgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_rate'
|
||||
},
|
||||
sgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'sgst_amt'
|
||||
},
|
||||
igstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_rate'
|
||||
},
|
||||
igstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'igst_amt'
|
||||
},
|
||||
utgstRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_rate'
|
||||
},
|
||||
utgstAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'utgst_amt'
|
||||
},
|
||||
cessRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_rate'
|
||||
},
|
||||
cessAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'cess_amt'
|
||||
},
|
||||
totalAmt: {
|
||||
type: DataTypes.DECIMAL(15, 2),
|
||||
allowNull: true,
|
||||
field: 'total_amt'
|
||||
},
|
||||
isService: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false,
|
||||
field: 'is_service'
|
||||
},
|
||||
itemOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
|
||||
@ -26,7 +26,7 @@ interface InternalOrderAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> { }
|
||||
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
|
||||
public ioId!: string;
|
||||
@ -137,7 +137,7 @@ InternalOrder.init(
|
||||
indexes: [
|
||||
{
|
||||
fields: ['request_id'],
|
||||
unique: false
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: ['io_number']
|
||||
|
||||
@ -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
|
||||
@ -55,7 +52,7 @@ interface UserAttributes {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||
public userId!: string;
|
||||
|
||||
@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user