Compare commits

..

25 Commits

Author SHA1 Message Date
8753c9477d afer VAPT clearence code merged with dealercalim and commented claim flow related code 2026-03-03 19:33:14 +05:30
f679317d4a mime type code pulled fom remote 2026-03-03 18:11:51 +05:30
7488ae3bee multi iteration flow added and the external dealer api aded 2026-03-03 18:09:26 +05:30
5be1e319b0 dealer from external source implemented and re-iteration and multiple io block implemented need to test end to end 2026-03-02 21:34:59 +05:30
4c745297d4 test gs number mapped for uat/development 2026-03-02 14:04:52 +05:30
ba5cb952b9 i have modified invoice and gst related fields for actuall data mapping still getting validation error after making thses changes 2026-02-27 19:46:42 +05:30
2fee63dc44 mime type issue resolved 2026-02-26 21:22:44 +05:30
3c8fed7d2f infected header scanning enhanced 2026-02-26 18:43:21 +05:30
aa18e3c34d dev build added 2026-02-26 17:20:17 +05:30
55804671e5 non-gdty flow modified and docker compose updated 2026-02-26 16:10:15 +05:30
aaa249c341 activity type route secured 2026-02-25 17:57:58 +05:30
c099cae4e7 rate limit added and sap integrtin related hardcoded sap client id removed ready with new scanner changes 2026-02-25 16:49:00 +05:30
dbb088dbcc malware scan and sanitization implemetation done 2026-02-24 19:34:09 +05:30
9fd9c218df inplemented the gst and non gst invoice generation flow and cost item table enhanced 2026-02-20 20:39:36 +05:30
896b345e02 modified cost item and cost expens based on the new changes aksed similar hsn items will be clubbed and enhanced the activity type to support documnt type and gst type 2026-02-17 20:36:21 +05:30
ff20bb7ef8 toke generation from profile and enhanced cost item to support hsn 2026-02-16 20:02:07 +05:30
60c5d4b475 okta url issue partial url 2026-02-13 19:11:03 +05:30
e6059bc5bc csp issue for drop down hash code added 2026-02-13 18:25:46 +05:30
0e1c1d01c8 app domain isuein frontend resolved 2026-02-13 15:55:49 +05:30
00e1d51c66 vulnnearable comments removed and source exposing to frobrowser disabled worknote XSS fixed 2026-02-13 14:46:08 +05:30
b32a3505ac sanitized code removed url and mails 2026-02-12 20:57:41 +05:30
81afd7ec96 pwc invoice generation implemented and tables enhanced to support envvoice fields 2026-02-10 20:20:43 +05:30
9060c39f9c removed suspicious comments 2026-02-10 09:54:24 +05:30
17c62d2b45 enhancd expens and cost items to support gst values addd pwc service file to integrate pwc 2026-02-09 20:50:17 +05:30
2282d29322 afteer enabling dealer on frontend db_password fetch from google sectrets resolved , secret fech db connection order enhanced 2026-02-09 15:18:24 +05:30
172 changed files with 9792 additions and 2330 deletions

View File

@ -1326,9 +1326,9 @@ GCP_KEY_FILE=./config/gcp-key.json
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=notifications@royalenfield.com SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your_smtp_password SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
# AI Service (for conclusion generation) # AI Service (for conclusion generation)
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key

View File

@ -155,13 +155,13 @@ export async function calculateBusinessDays(
2. ✅ Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils` 2. ✅ Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils`
3. ✅ Replaced lines 64-65 with proper working hours calculation (now lines 66-77) 3. ✅ Replaced lines 64-65 with proper working hours calculation (now lines 66-77)
4. ✅ Gets priority from workflow 4. ✅ Gets priority from workflow
5. **TODO:** Test TAT breach alerts 5. Done: Test TAT breach alerts
### Step 2: Add Business Days Function ✅ **DONE** ### Step 2: Add Business Days Function ✅ **DONE**
1. ✅ Opened `Re_Backend/src/utils/tatTimeUtils.ts` 1. ✅ Opened `Re_Backend/src/utils/tatTimeUtils.ts`
2. ✅ Added `calculateBusinessDays()` function (lines 697-758) 2. ✅ Added `calculateBusinessDays()` function (lines 697-758)
3. ✅ Exported the function 3. ✅ Exported the function
4. **TODO:** Test with various date ranges 4. Done: Test with various date ranges
### Step 3: Update Workflow Aging Report ✅ **DONE** ### Step 3: Update Workflow Aging Report ✅ **DONE**
1. ✅ Built report endpoint using `calculateBusinessDays()` 1. ✅ Built report endpoint using `calculateBusinessDays()`

View File

@ -19,10 +19,10 @@ This command will output something like:
``` ```
======================================= =======================================
Public Key: Public Key:
BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6 {{VAPID_PUBLIC_KEY}}
Private Key: Private Key:
aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz {{VAPID_PRIVATE_KEY}}
======================================= =======================================
``` ```
@ -59,9 +59,9 @@ Add the generated keys to your backend `.env` file:
```env ```env
# Notification Service Worker credentials (Web Push / VAPID) # Notification Service Worker credentials (Web Push / VAPID)
VAPID_PUBLIC_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6 VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
VAPID_PRIVATE_KEY=aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz VAPID_PRIVATE_KEY={{VAPID_PRIVATE_KEY}}
VAPID_CONTACT=mailto:admin@royalenfield.com VAPID_CONTACT=mailto:{{ADMIN_EMAIL}}
``` ```
**Important Notes:** **Important Notes:**
@ -75,7 +75,7 @@ Add the **SAME** `VAPID_PUBLIC_KEY` to your frontend `.env` file:
```env ```env
# Push Notifications (Web Push / VAPID) # Push Notifications (Web Push / VAPID)
VITE_PUBLIC_VAPID_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6 VITE_PUBLIC_VAPID_KEY={{VAPID_PUBLIC_KEY}}
``` ```
**Important:** **Important:**

View File

@ -98,7 +98,7 @@ npm run dev
1. Server will start automatically 1. Server will start automatically
2. Log in via SSO 2. Log in via SSO
3. Run this SQL to make yourself admin: 3. Run this SQL to make yourself admin:
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@royalenfield.com'; UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{APP_DOMAIN}}';
[Config Seed] ✅ Default configurations seeded successfully (30 settings) [Config Seed] ✅ Default configurations seeded successfully (30 settings)
info: ✅ Server started successfully on port 5000 info: ✅ Server started successfully on port 5000
@ -112,7 +112,7 @@ psql -d royal_enfield_workflow
UPDATE users UPDATE users
SET role = 'ADMIN' SET role = 'ADMIN'
WHERE email = 'your-email@royalenfield.com'; WHERE email = 'your-email@{{APP_DOMAIN}}';
\q \q
``` ```

View File

@ -471,7 +471,7 @@ The backend supports web push notifications via VAPID (Voluntary Application Ser
``` ```
VAPID_PUBLIC_KEY=<your-public-key> VAPID_PUBLIC_KEY=<your-public-key>
VAPID_PRIVATE_KEY=<your-private-key> VAPID_PRIVATE_KEY=<your-private-key>
VAPID_CONTACT=mailto:admin@royalenfield.com VAPID_CONTACT=mailto:admin@{{APP_DOMAIN}}
``` ```
3. **Add to Frontend `.env`:** 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

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1 @@
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}; 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};
//# sourceMappingURL=conclusionApi-VENY18zj.js.map

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,18 +10,18 @@
<meta name="theme-color" content="#2d4a3e" /> <meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title> <title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons --> <!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-BgkDE8Pi.js"></script> <script type="module" crossorigin src="/assets/index-hYhqmPqT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.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/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-Bmv9jJki.js">
<link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css"> <link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css">
</head> </head>
<body> <body>

4
build/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Disallow: /api/
Sitemap: https://reflow.royalenfield.com/sitemap.xml

9
build/sitemap.xml Normal file
View File

@ -0,0 +1,9 @@
<?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>

View File

@ -1,39 +1,8 @@
# ============================================================================= # docker-compose.full.yml
# RE Workflow - Full Stack Docker Compose # Synced with streamlined infrastructure
# Includes: Application + Database + Monitoring Stack
# =============================================================================
# Usage:
# docker-compose -f docker-compose.full.yml up -d
# =============================================================================
version: '3.8' version: '3.8'
services: 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: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: re_workflow_redis container_name: re_workflow_redis
@ -50,70 +19,24 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
backend: clamav:
build: image: clamav/clamav:latest
context: . container_name: re_clamav
dockerfile: Dockerfile ports:
container_name: re_workflow_backend - "3310:3310"
volumes:
- clamav_data:/var/lib/clamav
environment: environment:
NODE_ENV: development - CLAMAV_NO_FRESHCLAMD=false
DB_HOST: postgres healthcheck:
DB_PORT: 5432 test: ["CMD", "clamdcheck"]
DB_USER: ${DB_USER:-laxman} interval: 60s
DB_PASSWORD: ${DB_PASSWORD:-Admin@123} timeout: 10s
DB_NAME: ${DB_NAME:-re_workflow_db} retries: 5
REDIS_URL: redis://redis:6379 start_period: 120s
PORT: 5000 restart: unless-stopped
# 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: networks:
- re_workflow_network - 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: loki:
image: grafana/loki:2.9.2 image: grafana/loki:2.9.2
@ -156,15 +79,12 @@ services:
- GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024 - GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
- GF_USERS_ALLOW_SIGN_UP=false - 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: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro - ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on: depends_on:
- prometheus
- loki - loki
networks: networks:
- re_workflow_network - re_workflow_network
@ -175,54 +95,13 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
node-exporter:
image: prom/node-exporter:v1.6.1
container_name: re_node_exporter
ports:
- "9100:9100"
networks:
- re_workflow_network
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.26.0
container_name: re_alertmanager
ports:
- "9093:9093"
volumes: volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro redis_data:
- alertmanager_data:/alertmanager clamav_data:
command: loki_data:
- '--config.file=/etc/alertmanager/alertmanager.yml' promtail_data:
- '--storage.path=/alertmanager' grafana_data:
networks:
- re_workflow_network
restart: unless-stopped
# ===========================================================================
# NETWORKS
# ===========================================================================
networks: networks:
re_workflow_network: re_workflow_network:
driver: bridge driver: bridge
name: re_workflow_network
# ===========================================================================
# VOLUMES
# ===========================================================================
volumes:
postgres_data:
name: re_postgres_data
redis_data:
name: re_redis_data
prometheus_data:
name: re_prometheus_data
loki_data:
name: re_loki_data
promtail_data:
name: re_promtail_data
grafana_data:
name: re_grafana_data
alertmanager_data:
name: re_alertmanager_data

View File

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

View File

@ -0,0 +1,71 @@
# 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 |

View File

@ -34,7 +34,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
- **Approver Type**: System (Auto-processed) - **Approver Type**: System (Auto-processed)
- **Action Type**: **AUTO** (System automatically creates activity) - **Action Type**: **AUTO** (System automatically creates activity)
- **TAT**: 1 hour - **TAT**: 1 hour
- **Mapping**: System user (`system@royalenfield.com`) - **Mapping**: System user (`system@{{APP_DOMAIN}}`)
- **Status**: Auto-approved when triggered - **Status**: Auto-approved when triggered
### Step 5: Dealer Completion Documents ### 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) - **Approver Type**: System (Auto-processed via DMS)
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration) - **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
- **TAT**: 1 hour - **TAT**: 1 hour
- **Mapping**: System user (`system@royalenfield.com`) - **Mapping**: System user (`system@{{APP_DOMAIN}}`)
- **Status**: Auto-approved when triggered - **Status**: Auto-approved when triggered
### Step 8: Credit Note Confirmation ### 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' 1. Find user with department containing "Finance" and role = 'MANAGEMENT'
2. Find user with designation containing "Finance" or "Accountant" 2. Find user with designation containing "Finance" or "Accountant"
3. Use configured finance team email from admin_configurations table 3. Use configured finance team email from admin_configurations table
4. Fallback: Use default finance email (e.g., finance@royalenfield.com) 4. Fallback: Use default finance email (e.g., finance@{{APP_DOMAIN}})
``` ```
## Next Steps ## Next Steps

View File

@ -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) | | `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) | | `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
| `single_format_month_year` | String(50) | No | Format: Sep-2014 | | `single_format_month_year` | String(50) | No | Format: Sep-2014 |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) | | `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
| `replacement` | String(50) | No | Replacement status | | `replacement` | String(50) | No | Replacement status |
| `termination_resignation_status` | String(255) | No | Termination/Resignation status | | `termination_resignation_status` | String(255) | No | Termination/Resignation status |
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD | | `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
```csv ```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 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.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 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
``` ```
**What gets auto-generated:** **What gets auto-generated:**

View File

@ -0,0 +1,29 @@
# 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.

View File

@ -56,7 +56,7 @@ users {
```json ```json
{ {
"userId": "uuid-1", "userId": "uuid-1",
"email": "john.doe@royalenfield.com", "email": "john.doe@{{APP_DOMAIN}}",
"employeeId": "E12345", // Regular employee ID "employeeId": "E12345", // Regular employee ID
"designation": "Software Engineer", "designation": "Software Engineer",
"department": "IT", "department": "IT",
@ -68,7 +68,7 @@ users {
```json ```json
{ {
"userId": "uuid-2", "userId": "uuid-2",
"email": "test.2@royalenfield.com", "email": "test.2@{{APP_DOMAIN}}",
"employeeId": "RE-MH-001", // Dealer code stored here "employeeId": "RE-MH-001", // Dealer code stored here
"designation": "Dealer", "designation": "Dealer",
"department": "Dealer Operations", "department": "Dealer Operations",

View File

@ -98,8 +98,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
**Base URL Examples:** **Base URL Examples:**
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice` - Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` - UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice` - Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
### 3.2 Request Headers ### 3.2 Request Headers
@ -205,8 +205,8 @@ User-Agent: DMS-Webhook-Client/1.0
**Base URL Examples:** **Base URL Examples:**
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note` - Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` - UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note` - Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
### 4.2 Request Headers ### 4.2 Request Headers
@ -563,8 +563,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
| Environment | Invoice Webhook URL | Credit Note Webhook URL | | 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` | | Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/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` | | 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.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow.royalenfield.com/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` |
--- ---

View File

@ -157,7 +157,7 @@ npm run seed:config
```bash ```bash
# Edit the script # Edit the script
nano scripts/assign-admin-user.sql nano scripts/assign-admin-user.sql
# Change: YOUR_EMAIL@royalenfield.com # Change: YOUR_EMAIL@{{APP_DOMAIN}}
# Run it # Run it
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
@ -170,7 +170,7 @@ psql -d royal_enfield_workflow
UPDATE users UPDATE users
SET role = 'ADMIN' SET role = 'ADMIN'
WHERE email = 'your-email@royalenfield.com'; WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Verify -- Verify
SELECT email, role FROM users WHERE role = 'ADMIN'; 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" psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
# Check your user # Check your user
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@royalenfield.com';" psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';"
``` ```
--- ---
@ -241,13 +241,13 @@ Expected output:
```sql ```sql
-- Single user -- Single user
UPDATE users SET role = 'MANAGEMENT' UPDATE users SET role = 'MANAGEMENT'
WHERE email = 'manager@royalenfield.com'; WHERE email = 'manager@{{APP_DOMAIN}}';
-- Multiple users -- Multiple users
UPDATE users SET role = 'MANAGEMENT' UPDATE users SET role = 'MANAGEMENT'
WHERE email IN ( WHERE email IN (
'manager1@royalenfield.com', 'manager1@{{APP_DOMAIN}}',
'manager2@royalenfield.com' 'manager2@{{APP_DOMAIN}}'
); );
-- By department -- By department
@ -260,13 +260,13 @@ WHERE department = 'Management' AND is_active = true;
```sql ```sql
-- Single user -- Single user
UPDATE users SET role = 'ADMIN' UPDATE users SET role = 'ADMIN'
WHERE email = 'admin@royalenfield.com'; WHERE email = 'admin@{{APP_DOMAIN}}';
-- Multiple admins -- Multiple admins
UPDATE users SET role = 'ADMIN' UPDATE users SET role = 'ADMIN'
WHERE email IN ( WHERE email IN (
'admin1@royalenfield.com', 'admin1@{{APP_DOMAIN}}',
'admin2@royalenfield.com' 'admin2@{{APP_DOMAIN}}'
); );
-- By department -- By department
@ -331,7 +331,7 @@ SELECT
mobile_phone, mobile_phone,
array_length(ad_groups, 1) as ad_group_count array_length(ad_groups, 1) as ad_group_count
FROM users FROM users
WHERE email = 'your-email@royalenfield.com'; WHERE email = 'your-email@{{APP_DOMAIN}}';
``` ```
--- ---
@ -344,7 +344,7 @@ WHERE email = 'your-email@royalenfield.com';
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \ curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "test@royalenfield.com", "email": "test@{{APP_DOMAIN}}",
"displayName": "Test User", "displayName": "Test User",
"oktaSub": "test-sub-123" "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 ### 2. Check User Created with Default Role
```sql ```sql
SELECT email, role FROM users WHERE email = 'test@royalenfield.com'; SELECT email, role FROM users WHERE email = 'test@{{APP_DOMAIN}}';
-- Expected: role = 'USER' -- Expected: role = 'USER'
``` ```
### 3. Update to ADMIN ### 3. Update to ADMIN
```sql ```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com'; UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
``` ```
### 4. Verify API Access ### 4. Verify API Access
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
# Login and get token # Login and get token
curl -X POST http://localhost:5000/api/v1/auth/login \ curl -X POST http://localhost:5000/api/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"email": "test@royalenfield.com", ...}' -d '{"email": "test@{{APP_DOMAIN}}", ...}'
# Try admin endpoint (should work if ADMIN role) # Try admin endpoint (should work if ADMIN role)
curl http://localhost:5000/api/v1/admin/configurations \ curl http://localhost:5000/api/v1/admin/configurations \
@ -449,7 +449,7 @@ npm run migrate
```sql ```sql
-- Check if user exists -- Check if user exists
SELECT * FROM users WHERE email = 'your-email@royalenfield.com'; SELECT * FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Check Okta sub -- Check Okta sub
SELECT * FROM users WHERE okta_sub = 'your-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 ```sql
-- Verify role -- Verify role
SELECT email, role, is_active FROM users WHERE email = 'your-email@royalenfield.com'; SELECT email, role, is_active FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Check role enum -- Check role enum
\dT+ user_role_enum \dT+ user_role_enum

View File

@ -29,7 +29,7 @@ This guide provides step-by-step instructions for setting up Google Cloud Storag
|------|------------------| |------|------------------|
| **Application** | Royal Enfield Workflow System | | **Application** | Royal Enfield Workflow System |
| **Environment** | Production | | **Environment** | Production |
| **Domain** | `https://reflow.royalenfield.com` | | **Domain** | `https://reflow.{{APP_DOMAIN}}` |
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes | | **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
| **Storage Type** | Google Cloud Storage (GCS) | | **Storage Type** | Google Cloud Storage (GCS) |
| **Region** | `asia-south1` (Mumbai) | | **Region** | `asia-south1` (Mumbai) |
@ -325,8 +325,8 @@ Create `cors-config-prod.json`:
[ [
{ {
"origin": [ "origin": [
"https://reflow.royalenfield.com", "https://reflow.{{APP_DOMAIN}}",
"https://www.royalenfield.com" "https://www.{{APP_DOMAIN}}"
], ],
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"], "method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
"responseHeader": [ "responseHeader": [

View File

@ -6,7 +6,7 @@
|------|-------| |------|-------|
| **Application** | RE Workflow System | | **Application** | RE Workflow System |
| **Environment** | UAT | | **Environment** | UAT |
| **Domain** | https://reflow-uat.royalenfield.com | | **Domain** | https://reflow-uat.{{APP_DOMAIN}} |
| **Purpose** | Store workflow documents and attachments | | **Purpose** | Store workflow documents and attachments |
--- ---
@ -131,8 +131,8 @@ Apply this CORS policy to allow browser uploads:
[ [
{ {
"origin": [ "origin": [
"https://reflow-uat.royalenfield.com", "https://reflow-uat.{{APP_DOMAIN}}",
"https://reflow.royalenfield.com" "https://reflow.{{APP_DOMAIN}}"
], ],
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"], "method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
"responseHeader": [ "responseHeader": [

View File

@ -72,8 +72,8 @@ The Users API returns a complete user object:
"employeeID": "E09994", "employeeID": "E09994",
"title": "Supports Business Applications (SAP) portfolio", "title": "Supports Business Applications (SAP) portfolio",
"department": "Deputy Manager - Digital & IT", "department": "Deputy Manager - Digital & IT",
"login": "sanjaysahu@Royalenfield.com", "login": "sanjaysahu@{{APP_DOMAIN}}",
"email": "sanjaysahu@royalenfield.com" "email": "sanjaysahu@{{APP_DOMAIN}}"
}, },
... ...
} }
@ -127,7 +127,7 @@ Example log:
### Test with curl ### Test with curl
```bash ```bash
curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \ curl --location 'https://{{IDP_DOMAIN}}/api/v1/users/testuser10@eichergroup.com' \
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \ --header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
--header 'Accept: application/json' --header 'Accept: application/json'
``` ```

View File

@ -450,16 +450,16 @@ Before Migration:
+-------------------------+-----------+ +-------------------------+-----------+
| email | is_admin | | email | is_admin |
+-------------------------+-----------+ +-------------------------+-----------+
| admin@royalenfield.com | true | | admin@{{APP_DOMAIN}} | true |
| user1@royalenfield.com | false | | user1@{{APP_DOMAIN}} | false |
+-------------------------+-----------+ +-------------------------+-----------+
After Migration: After Migration:
+-------------------------+-----------+-----------+ +-------------------------+-----------+-----------+
| email | role | is_admin | | email | role | is_admin |
+-------------------------+-----------+-----------+ +-------------------------+-----------+-----------+
| admin@royalenfield.com | ADMIN | true | | admin@{{APP_DOMAIN}} | ADMIN | true |
| user1@royalenfield.com | USER | false | | user1@{{APP_DOMAIN}} | USER | false |
+-------------------------+-----------+-----------+ +-------------------------+-----------+-----------+
``` ```
@ -473,17 +473,17 @@ After Migration:
-- Make user a MANAGEMENT role -- Make user a MANAGEMENT role
UPDATE users UPDATE users
SET role = 'MANAGEMENT', is_admin = false SET role = 'MANAGEMENT', is_admin = false
WHERE email = 'manager@royalenfield.com'; WHERE email = 'manager@{{APP_DOMAIN}}';
-- Make user an ADMIN role -- Make user an ADMIN role
UPDATE users UPDATE users
SET role = 'ADMIN', is_admin = true SET role = 'ADMIN', is_admin = true
WHERE email = 'admin@royalenfield.com'; WHERE email = 'admin@{{APP_DOMAIN}}';
-- Revert to USER role -- Revert to USER role
UPDATE users UPDATE users
SET role = 'USER', is_admin = false SET role = 'USER', is_admin = false
WHERE email = 'user@royalenfield.com'; WHERE email = 'user@{{APP_DOMAIN}}';
``` ```
### Via API (Admin Endpoint) ### Via API (Admin Endpoint)

View File

@ -47,12 +47,12 @@ psql -d royal_enfield_db -f scripts/assign-user-roles.sql
-- Make specific users ADMIN -- Make specific users ADMIN
UPDATE users UPDATE users
SET role = 'ADMIN', is_admin = true SET role = 'ADMIN', is_admin = true
WHERE email IN ('admin@royalenfield.com', 'it.admin@royalenfield.com'); WHERE email IN ('admin@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}');
-- Make specific users MANAGEMENT -- Make specific users MANAGEMENT
UPDATE users UPDATE users
SET role = 'MANAGEMENT', is_admin = false SET role = 'MANAGEMENT', is_admin = false
WHERE email IN ('manager@royalenfield.com', 'auditor@royalenfield.com'); WHERE email IN ('manager@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}');
-- Verify roles -- Verify roles
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email; SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
@ -219,7 +219,7 @@ GROUP BY role;
-- Check specific user -- Check specific user
SELECT email, role, is_admin SELECT email, role, is_admin
FROM users FROM users
WHERE email = 'your-email@royalenfield.com'; WHERE email = 'your-email@{{APP_DOMAIN}}';
``` ```
### Test 2: Test API Access ### Test 2: Test API Access
@ -356,7 +356,7 @@ WHERE designation ILIKE '%manager%' OR designation ILIKE '%head%';
```sql ```sql
SELECT email, role, is_admin SELECT email, role, is_admin
FROM users FROM users
WHERE email = 'your-email@royalenfield.com'; WHERE email = 'your-email@{{APP_DOMAIN}}';
``` ```
--- ---

View File

@ -314,7 +314,7 @@ JWT_EXPIRY=24h
REFRESH_TOKEN_EXPIRY=7d REFRESH_TOKEN_EXPIRY=7d
# Okta Configuration # Okta Configuration
OKTA_DOMAIN=https://dev-830839.oktapreview.com OKTA_DOMAIN=https://{{IDP_DOMAIN}}
OKTA_CLIENT_ID=your-client-id OKTA_CLIENT_ID=your-client-id
OKTA_CLIENT_SECRET=your-client-secret OKTA_CLIENT_SECRET=your-client-secret
@ -334,7 +334,7 @@ GCP_BUCKET_PUBLIC=true
**Identity Provider**: Okta **Identity Provider**: Okta
- **Domain**: Configurable via `OKTA_DOMAIN` environment variable - **Domain**: Configurable via `OKTA_DOMAIN` environment variable
- **Default**: `https://dev-830839.oktapreview.com` - **Default**: `https://{{IDP_DOMAIN}}`
- **Protocol**: OAuth 2.0 / OpenID Connect (OIDC) - **Protocol**: OAuth 2.0 / OpenID Connect (OIDC)
- **Grant Types**: Authorization Code, Resource Owner Password Credentials - **Grant Types**: Authorization Code, Resource Owner Password Credentials
@ -650,7 +650,7 @@ graph LR
{ {
"userId": "uuid", "userId": "uuid",
"employeeId": "EMP001", "employeeId": "EMP001",
"email": "user@royalenfield.com", "email": "user@{{APP_DOMAIN}}",
"role": "USER" | "MANAGEMENT" | "ADMIN", "role": "USER" | "MANAGEMENT" | "ADMIN",
"iat": 1234567890, "iat": 1234567890,
"exp": 1234654290 "exp": 1234654290
@ -1048,7 +1048,7 @@ JWT_EXPIRY=24h
REFRESH_TOKEN_EXPIRY=7d REFRESH_TOKEN_EXPIRY=7d
# Okta # Okta
OKTA_DOMAIN=https://dev-830839.oktapreview.com OKTA_DOMAIN=https://{{IDP_DOMAIN}}
OKTA_CLIENT_ID=your-client-id OKTA_CLIENT_ID=your-client-id
OKTA_CLIENT_SECRET=your-client-secret OKTA_CLIENT_SECRET=your-client-secret
@ -1063,7 +1063,7 @@ GCP_BUCKET_PUBLIC=true
**Frontend (.env):** **Frontend (.env):**
```env ```env
VITE_API_BASE_URL=https://api.rebridge.co.in/api/v1 VITE_API_BASE_URL=https://api.rebridge.co.in/api/v1
VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com VITE_OKTA_DOMAIN=https://{{IDP_DOMAIN}}
VITE_OKTA_CLIENT_ID=your-client-id VITE_OKTA_CLIENT_ID=your-client-id
``` ```

View File

@ -64,7 +64,7 @@ await this.createClaimApprovalLevels(
isAuto: false, isAuto: false,
approverType: 'department_lead' as const, approverType: 'department_lead' as const,
approverId: departmentLead?.userId || null, approverId: departmentLead?.userId || null,
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com', approverEmail: departmentLead?.email || initiator.manager || `deptlead@${appDomain}`,
} }
``` ```

View File

@ -181,7 +181,7 @@ POST http://localhost:5000/api/v1/auth/login
Content-Type: application/json Content-Type: application/json
{ {
"username": "john.doe@royalenfield.com", "username": "john.doe@{{APP_DOMAIN}}",
"password": "SecurePassword123!" "password": "SecurePassword123!"
} }
``` ```

View File

@ -26,8 +26,8 @@ REFRESH_TOKEN_EXPIRY=7d
SESSION_SECRET=your_session_secret_here_min_32_chars SESSION_SECRET=your_session_secret_here_min_32_chars
# Cloud Storage (GCP) # Cloud Storage (GCP)
GCP_PROJECT_ID=re-workflow-project GCP_PROJECT_ID={{GCP_PROJECT_ID}}
GCP_BUCKET_NAME=re-workflow-documents GCP_BUCKET_NAME={{GCP_BUCKET_NAME}}
GCP_KEY_FILE=./config/gcp-key.json GCP_KEY_FILE=./config/gcp-key.json
# Google Secret Manager (Optional - for production) # Google Secret Manager (Optional - for production)
@ -41,9 +41,9 @@ USE_GOOGLE_SECRET_MANAGER=false
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=notifications@royalenfield.com SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your_smtp_password SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
# AI Service (for conclusion generation) - Vertex AI Gemini # AI Service (for conclusion generation) - Vertex AI Gemini
# Uses service account credentials from GCP_KEY_FILE # Uses service account credentials from GCP_KEY_FILE
@ -55,7 +55,7 @@ VERTEX_AI_LOCATION=asia-south1
# Logging # Logging
LOG_LEVEL=info LOG_LEVEL=info
LOG_FILE_PATH=./logs LOG_FILE_PATH=./logs
APP_VERSION=1.2.0 APP_VERSION={{APP_VERSION}}
# ============ Loki Configuration (Grafana Log Aggregation) ============ # ============ Loki Configuration (Grafana Log Aggregation) ============
LOKI_HOST= # e.g., http://loki:3100 or http://monitoring.cloudtopiaa.com:3100 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="*" CORS_ORIGIN="*"
# Rate Limiting # Rate Limiting
RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=100 RATE_LIMIT_MAX_REQUESTS=100
# File Upload # File Upload
@ -83,16 +83,16 @@ OKTA_CLIENT_ID={{okta_client_id}}
OKTA_CLIENT_SECRET={{okta_client_secret}} OKTA_CLIENT_SECRET={{okta_client_secret}}
# Notificaton Service Worker credentials # Notificaton Service Worker credentials
VAPID_PUBLIC_KEY={{vapid_public_key}} note: same key need to add on front end for web push VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
VAPID_PRIVATE_KEY={{vapid_private_key}} VAPID_PRIVATE_KEY={{vapid_private_key}}
VAPID_CONTACT=mailto:you@example.com VAPID_CONTACT=mailto:you@example.com
#Redis #Redis
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}} REDIS_URL={{REDIS_URL}}
TAT_TEST_MODE=false (on true it will consider 1 hour==1min) TAT_TEST_MODE=false # Set to true to accelerate TAT for testing
# SAP Integration (OData Service via Zscaler) # SAP Integration (OData Service via Zscaler)
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443 SAP_BASE_URL=https://{{SAP_DOMAIN_HERE}}:{{PORT}}
SAP_USERNAME={{SAP_USERNAME}} SAP_USERNAME={{SAP_USERNAME}}
SAP_PASSWORD={{SAP_PASSWORD}} SAP_PASSWORD={{SAP_PASSWORD}}
SAP_TIMEOUT_MS=30000 SAP_TIMEOUT_MS=30000

View File

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

847
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)", "description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
"start": "npm run build && npm run start:prod && npm run setup", "start": "npm install && npm run build && npm run setup && npm run start:prod",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "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", "dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"build": "tsc && tsc-alias", "build": "tsc && tsc-alias",
@ -30,6 +30,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bullmq": "^5.63.0", "bullmq": "^5.63.0",
"clamscan": "^2.4.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
@ -50,6 +51,8 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"puppeteer": "^24.37.2",
"sanitize-html": "^2.17.1",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
@ -72,6 +75,7 @@
"@types/passport": "^1.0.16", "@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/eslint-plugin": "^8.19.1",

View File

@ -16,7 +16,7 @@
UPDATE users UPDATE users
SET role = 'ADMIN' SET role = 'ADMIN'
WHERE email = 'YOUR_EMAIL@royalenfield.com' -- ← CHANGE THIS WHERE email = 'YOUR_EMAIL@{{APP_DOMAIN}}' -- ← CHANGE THIS
RETURNING RETURNING
user_id, user_id,
email, email,

View File

@ -21,9 +21,9 @@
UPDATE users UPDATE users
SET role = 'ADMIN' SET role = 'ADMIN'
WHERE email IN ( WHERE email IN (
'admin@royalenfield.com', 'admin@{{APP_DOMAIN}}',
'it.admin@royalenfield.com', 'it.admin@{{APP_DOMAIN}}',
'system.admin@royalenfield.com' 'system.admin@{{APP_DOMAIN}}'
-- Add more admin emails here -- Add more admin emails here
); );
@ -45,9 +45,9 @@ ORDER BY email;
UPDATE users UPDATE users
SET role = 'MANAGEMENT' SET role = 'MANAGEMENT'
WHERE email IN ( WHERE email IN (
'manager1@royalenfield.com', 'manager1@{{APP_DOMAIN}}',
'dept.head@royalenfield.com', 'dept.head@{{APP_DOMAIN}}',
'auditor@royalenfield.com' 'auditor@{{APP_DOMAIN}}'
-- Add more management emails here -- Add more management emails here
); );

View File

@ -162,7 +162,7 @@ SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=${SMTP_USER} SMTP_USER=${SMTP_USER}
SMTP_PASSWORD=${SMTP_PASSWORD} SMTP_PASSWORD=${SMTP_PASSWORD}
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
# Vertex AI Gemini Configuration (for conclusion generation) # Vertex AI Gemini Configuration (for conclusion generation)
# Service account credentials should be placed in ./credentials/ folder # 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 " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
echo "" echo ""
echo "5. The VAPID_CONTACT should be a valid mailto: URL" echo "5. The VAPID_CONTACT should be a valid mailto: URL"
echo " Example: mailto:admin@royalenfield.com" echo " Example: mailto:admin@{{APP_DOMAIN}}"
echo "" echo ""
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!" echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
echo "" echo ""

View File

@ -7,10 +7,14 @@ import { UserService } from './services/user.service';
import { SSOUserData } from './types/auth.types'; import { SSOUserData } from './types/auth.types';
import { sequelize } from './config/database'; import { sequelize } from './config/database';
import { corsMiddleware } from './middlewares/cors.middleware'; 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 { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
import routes from './routes/index'; import routes from './routes/index';
import { ensureUploadDir, UPLOAD_DIR } from './config/storage'; import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
import { initializeGoogleSecretManager } from './services/googleSecretManager.service'; import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
import { rateLimiter } from './middlewares/rateLimiter.middleware';
import path from 'path'; import path from 'path';
// Load environment variables from .env file first // Load environment variables from .env file first
@ -23,7 +27,7 @@ const app: express.Application = express();
// 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc. // 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. // This handles a specific Express/Helmet edge case where redirects lose headers.
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'local';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
// Build connect-src dynamically // Build connect-src dynamically
@ -36,8 +40,10 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
connectSrc.push(...origins); connectSrc.push(...origins);
} }
const apiDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Define strict CSP directives // Define strict CSP directives
// CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance //: 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). // even if the header is truncated in certain response types (like 301 redirects).
const directives = [ const directives = [
"frame-ancestors 'self'", "frame-ancestors 'self'",
@ -45,13 +51,13 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
"base-uri 'self'", "base-uri 'self'",
"default-src 'none'", "default-src 'none'",
`connect-src ${connectSrc.join(' ')}`, `connect-src ${connectSrc.join(' ')}`,
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='", "style-src '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='", "style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='",
"style-src-attr 'unsafe-inline'", "style-src-attr 'unsafe-inline'",
"script-src 'self'", "script-src 'self'",
"script-src-elem 'self'", "script-src-elem 'self'",
"script-src-attr 'none'", "script-src-attr 'none'",
"img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com", `img-src 'self' data: blob: https://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`,
"frame-src 'self' blob: data:", "frame-src 'self' blob: data:",
"font-src 'self' https://fonts.gstatic.com data:", "font-src 'self' https://fonts.gstatic.com data:",
"object-src 'none'", "object-src 'none'",
@ -85,18 +91,17 @@ app.use(cookieParser());
const userService = new UserService(); const userService = new UserService();
// Initialize database connection // Initializer for database connection (called from server.ts)
const initializeDatabase = async () => { export const initializeAppDatabase = async () => {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
console.log('✅ App database connection established');
} catch (error) { } catch (error) {
console.error('❌ Database connection failed:', error); console.error('❌ App database connection failed:', error);
throw error;
} }
}; };
// Initialize database
initializeDatabase();
// Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.) // Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.)
// This allows Express to read X-Forwarded-* headers correctly // This allows Express to read X-Forwarded-* headers correctly
// Set to true in production, false in development // Set to true in production, false in development
@ -111,6 +116,12 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, 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 // Logging middleware
app.use(morgan('combined')); app.use(morgan('combined'));
@ -118,7 +129,7 @@ app.use(morgan('combined'));
app.use(metricsMiddleware); app.use(metricsMiddleware);
// Prometheus metrics endpoint - expose metrics for scraping // Prometheus metrics endpoint - expose metrics for scraping
app.use(createMetricsRouter()); app.use('/metrics', authenticateToken, requireAdmin, createMetricsRouter());
// Health check endpoint (before API routes) // Health check endpoint (before API routes)
app.get('/health', (_req: express.Request, res: express.Response) => { app.get('/health', (_req: express.Request, res: express.Response) => {
@ -135,7 +146,16 @@ app.use('/api/v1', routes);
// Serve uploaded files statically // Serve uploaded files statically
ensureUploadDir(); ensureUploadDir();
app.use('/uploads', express.static(UPLOAD_DIR)); app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR));
// Initialize ClamAV toggle manager
import { initializeToggleFile } from './services/clamav/clamavToggleManager';
try {
initializeToggleFile();
console.log(`✅ ClamAV toggle initialized (ENABLE_CLAMAV=${process.env.ENABLE_CLAMAV || 'true'})`);
} catch (err) {
console.warn('⚠️ ClamAV toggle initialization warning:', err);
}
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility) // 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> => { app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
@ -189,7 +209,7 @@ app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.
}); });
// Get all users endpoint // Get all users endpoint
app.get('/api/v1/users', async (_req: express.Request, res: express.Response): Promise<void> => { app.get('/api/v1/users', authenticateToken, requireAdmin, async (_req: express.Request, res: express.Response): Promise<void> => {
try { try {
const users = await userService.getAllUsers(); const users = await userService.getAllUsers();

View File

@ -9,7 +9,7 @@ export const emailConfig = {
}, },
}, },
from: process.env.EMAIL_FROM || 'RE Workflow System <notifications@royalenfield.com>', from: process.env.EMAIL_FROM || `RE Workflow System <notifications@${process.env.APP_DOMAIN || 'royalenfield.com'}>`,
// Email templates // Email templates
templates: { templates: {

View File

@ -12,14 +12,14 @@ const ssoConfig: SSOConfig = {
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || []; return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
}, },
// Okta/Auth0 configuration for token exchange // Okta/Auth0 configuration for token exchange
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; }, get oktaDomain() { return process.env.OKTA_DOMAIN || `{{IDP_DOMAIN}}`; },
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; }, get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; }, get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
// Tanflow configuration for token exchange // Tanflow configuration for token exchange
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; }, get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || `{{IDP_DOMAIN}}/realms/RE`; },
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; }, get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; }, get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || `{{TANFLOW_CLIENT_SECRET}}`; },
}; };
export { ssoConfig }; export { ssoConfig };

View File

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

View File

@ -132,10 +132,13 @@ export class AuthController {
// Set new access token in cookie if using cookie-based auth // Set new access token in cookie if using cookie-based auth
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}; };
@ -148,7 +151,7 @@ export class AuthController {
message: 'Token refreshed successfully' message: 'Token refreshed successfully'
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
} else { } else {
// Development: Include token for debugging // Dev: Include token for debugging
ResponseHandler.success(res, { ResponseHandler.success(res, {
accessToken: newAccessToken accessToken: newAccessToken
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
@ -206,10 +209,13 @@ export class AuthController {
// Set tokens in httpOnly cookies (production) or return in body (development) // Set tokens in httpOnly cookies (production) or return in body (development)
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: isProduction ? ('lax' as const) : ('lax' as const), sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/', path: '/',
}; };
@ -256,10 +262,13 @@ export class AuthController {
// Set new access token in cookie // Set new access token in cookie
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: isProduction ? ('lax' as const) : ('lax' as const), sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
path: '/', path: '/',
}; };
@ -293,13 +302,16 @@ export class AuthController {
// Helper function to clear cookies with all possible option combinations // Helper function to clear cookies with all possible option combinations
const clearCookiesCompletely = () => { const clearCookiesCompletely = () => {
const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieNames = ['accessToken', 'refreshToken']; const cookieNames = ['accessToken', 'refreshToken'];
// Get the EXACT options used when setting cookies (from exchangeToken) // Get the EXACT options used when setting cookies (from exchangeToken)
// These MUST match exactly: httpOnly, secure, sameSite, path // These MUST match exactly: httpOnly, secure, sameSite, path
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', path: '/',
}; };
@ -469,10 +481,13 @@ export class AuthController {
// Set cookies for web clients // Set cookies for web clients
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: isProduction ? 'lax' as const : 'lax' as const, sameSite: isSecureEnv ? 'lax' as const : 'lax' as const,
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}; };
@ -549,10 +564,13 @@ export class AuthController {
// Set cookies with httpOnly flag for security // Set cookies with httpOnly flag for security
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isUat = process.env.NODE_ENV === 'uat';
const isSecureEnv = isProduction || isUat;
const cookieOptions = { const cookieOptions = {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isSecureEnv,
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' for same-domain sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
}; };
@ -584,7 +602,7 @@ export class AuthController {
idToken: result.oktaIdToken idToken: result.oktaIdToken
}, 'Token exchange successful'); }, 'Token exchange successful');
} else { } else {
// Development: Include tokens for debugging and different-port setup // Dev: Include tokens for debugging and different-port setup
ResponseHandler.success(res, { ResponseHandler.success(res, {
user: result.user, user: result.user,
accessToken: result.accessToken, accessToken: result.accessToken,

View File

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

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import type { AuthenticatedRequest } from '../types/express'; import type { AuthenticatedRequest } from '../types/express';
import { DealerClaimService } from '../services/dealerClaim.service'; import { DealerClaimService } from '../services/dealerClaim.service';
import { ResponseHandler } from '../utils/responseHandler'; import { ResponseHandler } from '../utils/responseHandler';
import { translateEInvoiceError } from '../utils/einvoiceErrors';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service'; import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document'; import { Document } from '../models/Document';
@ -11,6 +12,11 @@ import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto'; 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 { export class DealerClaimController {
private dealerClaimService = new DealerClaimService(); private dealerClaimService = new DealerClaimService();
@ -751,7 +757,66 @@ export class DealerClaimController {
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating e-invoice:', error); logger.error('[DealerClaimController] Error updating e-invoice:', error);
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
// Translate technical PWC/IRP error codes to user-friendly messages
const userFacingMessage = translateEInvoiceError(errorMessage);
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
}
}
/**
* 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);
} }
} }
@ -923,5 +988,98 @@ export class DealerClaimController {
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500); return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
} }
} }
/**
* Download Invoice CSV
* GET /api/v1/dealer-claims/:requestId/e-invoice/csv
*/
async downloadInvoiceCsv(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId;
// Use helper to find workflow
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
} }
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
// Fetch related data
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
ClaimInvoice.findOne({ where: { requestId } }),
ClaimInvoiceItem.findAll({ where: { requestId }, order: [['slNo', 'ASC']] }),
DealerClaimDetails.findOne({ where: { requestId } }),
InternalOrder.findOne({ where: { requestId } })
]);
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
let sapRefNo = '';
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
// Construct CSV
const headers = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_DATE',
'CLAIM_AMT',
'GST_AMT',
'GST_PERCENTAG'
];
const rows = items.map(item => {
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = claimDetails?.dealerCode || '';
const ioNumber = internalOrder?.ioNumber || '';
const claimDocTyp = sapRefNo;
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
// Zero out tax for Non-GST
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
return [
trnsUniqNo,
claimNumber,
invNumber,
dealerCode,
ioNumber,
claimDocTyp,
claimDate,
claimAmt,
totalTax.toFixed(2),
gstPercentag
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
res.status(200).send(csvContent);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice CSV:', error);
return ResponseHandler.error(res, 'Failed to download invoice CSV', 500, errorMessage);
}
}
}

View File

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

View File

@ -10,13 +10,37 @@ export class UserController {
this.userService = new UserService(); 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> { async searchUsers(req: Request, res: Response): Promise<void> {
try { try {
const q = String(req.query.q || '').trim(); const q = String(req.query.q || '').trim();
const limit = Number(req.query.limit || 10); 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 currentUserId = (req as any).user?.userId || (req as any).user?.id;
const users = await this.userService.searchUsers(q, limit, currentUserId); const users = await this.userService.searchUsers(q, limit, currentUserId, source);
const result = users.map(u => ({ const result = users.map(u => ({
userId: (u as any).userId, userId: (u as any).userId,
@ -69,6 +93,31 @@ 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) * Ensure user exists in database (create if not exists)
* Called when user is selected/tagged in the frontend * Called when user is selected/tagged in the frontend

View File

@ -12,7 +12,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service'; import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service';
import { DealerClaimService } from '@services/dealerClaim.service'; import { DealerClaimService } from '@services/dealerClaim.service';
import logger from '@utils/logger'; import logger from '@utils/logger';
@ -27,6 +27,15 @@ export class WorkflowController {
// Validate initiator exists // Validate initiator exists
await validateInitiator(req.user.userId); 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 // Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
let approvalLevels = validatedData.approvalLevels || []; let approvalLevels = validatedData.approvalLevels || [];
if (!approvalLevels.length && (req.body as any).approvers) { if (!approvalLevels.length && (req.body as any).approvers) {
@ -170,6 +179,15 @@ export class WorkflowController {
// Validate initiator exists // Validate initiator exists
await validateInitiator(userId); 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) // Use the approval levels from validation (already transformed above)
let approvalLevels = validated.approvalLevels || []; let approvalLevels = validated.approvalLevels || [];

View File

@ -172,7 +172,7 @@ This document outlines all email templates required for the Dealer Claim Managem
- Initiator (for record) - Initiator (for record)
- Finance team - Finance team
- **Template**: `creditNoteSent.template.ts` (NEW) - **Template**: `creditNoteSent.template.ts` (NEW)
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044) - **Status**: Required implementation
- **Notification Type**: `credit_note_sent` - **Notification Type**: `credit_note_sent`
- **Data Needed**: - **Data Needed**:
- Credit note number - Credit note number
@ -184,7 +184,7 @@ This document outlines all email templates required for the Dealer Claim Managem
- Reason for credit note - Reason for credit note
- Download link (if available) - Download link (if available)
- **Notes**: - **Notes**:
- Currently has TODO comment for email implementation - Planned for email implementation
- Critical for dealer notification - 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`) 5. **Credit Note Sent** (`creditNoteSent.template.ts`)
- Priority: High - Priority: High
- When: Credit note is sent to dealer (Step 8) - When: Credit note is sent to dealer (Step 8)
- Currently has TODO comment - Planned for implementation
--- ---
@ -255,7 +255,7 @@ This document outlines all email templates required for the Dealer Claim Managem
### High Priority (Critical for Workflow) ### High Priority (Critical for Workflow)
1. **Activity Created** - Currently using generic notification, should be branded 1. **Activity Created** - Currently using generic notification, should be branded
2. **E-Invoice Generated** - Important for financial tracking 2. **E-Invoice Generated** - Important for financial tracking
3. **Credit Note Sent** - Critical for dealer notification (currently TODO) 3. **Credit Note Sent** - Critical for dealer notification
### Medium Priority (Nice to Have) ### Medium Priority (Nice to Have)
4. **Proposal Submitted** - Better UX, but existing approval request works 4. **Proposal Submitted** - Better UX, but existing approval request works

View File

@ -991,9 +991,9 @@ Add to `.env`:
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=notifications@royalenfield.com SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your-app-specific-password SMTP_PASSWORD=your-app-specific-password
EMAIL_FROM=RE Flow <noreply@royalenfield.com> EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
# Email Settings # Email Settings
EMAIL_ENABLED=true EMAIL_ENABLED=true
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
EMAIL_RETRY_ATTEMPTS=3 EMAIL_RETRY_ATTEMPTS=3
# Application # Application
BASE_URL=https://workflow.royalenfield.com BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
COMPANY_WEBSITE=https://www.royalenfield.com COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
SUPPORT_EMAIL=support@royalenfield.com SUPPORT_EMAIL=support@{{APP_DOMAIN}}
``` ```
--- ---

View File

@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
All templates feature a single action button: All templates feature a single action button:
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now" - **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
- **Link Format:** `{baseURL}/request/{requestNumber}` - **Link Format:** `{baseURL}/request/{requestNumber}`
- **Example:** `https://workflow.royalenfield.com/request/REQ-2025-12-0013` - **Example:** `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
No approval/rejection buttons in emails - all actions happen within the application. 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 SMTP_PASSWORD=your-app-password
# Email Settings # Email Settings
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
BASE_URL=https://workflow.royalenfield.com BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
``` ```

View File

@ -361,7 +361,7 @@ All `[ViewDetailsLink]` placeholders should be replaced with:
{baseURL}/request/{requestNumber} {baseURL}/request/{requestNumber}
``` ```
Example: `https://workflow.royalenfield.com/request/REQ-2025-12-0013` Example: `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
### Company Name ### Company Name
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield") Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")

View File

@ -12,15 +12,15 @@ emailtemplates/
├── approvalRequest.template.ts ✅ Single approver email ├── approvalRequest.template.ts ✅ Single approver email
├── multiApproverRequest.template.ts ✅ Multi-approver email ├── multiApproverRequest.template.ts ✅ Multi-approver email
├── approvalConfirmation.template.ts 🔨 TODO ├── approvalConfirmation.template.ts ✅ DONE
├── rejectionNotification.template.ts 🔨 TODO ├── rejectionNotification.template.ts ✅ DONE
├── tatReminder.template.ts 🔨 TODO ├── tatReminder.template.ts ✅ DONE
├── tatBreached.template.ts 🔨 TODO ├── tatBreached.template.ts ✅ DONE
├── workflowPaused.template.ts 🔨 TODO ├── workflowPaused.template.ts ✅ DONE
├── workflowResumed.template.ts 🔨 TODO ├── workflowResumed.template.ts ✅ DONE
├── participantAdded.template.ts 🔨 TODO ├── participantAdded.template.ts ✅ DONE
├── approverSkipped.template.ts 🔨 TODO ├── approverSkipped.template.ts ✅ DONE
└── requestClosed.template.ts 🔨 TODO └── requestClosed.template.ts ✅ DONE
``` ```
--- ---
@ -53,7 +53,7 @@ const data: RequestCreatedData = {
requestTime: '02:30 PM', requestTime: '02:30 PM',
totalApprovers: 3, totalApprovers: 3,
expectedTAT: 48, expectedTAT: 48,
viewDetailsLink: 'https://workflow.royalenfield.com/request/REQ-2025-12-0013', viewDetailsLink: 'https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013',
companyName: 'Royal Enfield' companyName: 'Royal Enfield'
}; };
``` ```
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password SMTP_PASSWORD=your-app-password
# Email Settings # Email Settings
EMAIL_FROM=Royal Enfield Workflow <notifications@royalenfield.com> EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
# Application Settings # Application Settings
BASE_URL=https://workflow.royalenfield.com BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
``` ```

View File

@ -7,18 +7,20 @@
import { EmailHeaderConfig, EmailFooterConfig } from './helpers'; import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
/** /**
* Company Information * Company Information
*/ */
export const CompanyInfo = { export const CompanyInfo = {
name: 'Royal Enfield', name: 'Royal Enfield',
productName: 'RE Flow', // Product name displayed in header productName: 'RE Flow', // Product name displayed in header
website: 'https://www.royalenfield.com', website: `https://www.${appDomain}`,
supportEmail: 'support@royalenfield.com', supportEmail: `support@${appDomain}`,
// Logo configuration for email headers // Logo configuration for email headers
logo: { logo: {
url: 'https://www.royalenfield.com/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp', url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`,
alt: 'Royal Enfield Logo', alt: 'Royal Enfield Logo',
width: 220, // Logo width in pixels (wider for better visibility) width: 220, // Logo width in pixels (wider for better visibility)
height: 65, // Logo height in pixels (proportional ratio ~3.4:1) height: 65, // Logo height in pixels (proportional ratio ~3.4:1)

View File

@ -270,7 +270,7 @@ export async function shouldSendEmailWithOverride(
userId: string, userId: string,
emailType: EmailNotificationType emailType: EmailNotificationType
): Promise<boolean> { ): Promise<boolean> {
// Critical emails always sent (override user preference) // emails always sent (override user preference)
if (CRITICAL_EMAILS.includes(emailType)) { if (CRITICAL_EMAILS.includes(emailType)) {
const adminEnabled = await isAdminEmailEnabled(emailType); const adminEnabled = await isAdminEmailEnabled(emailType);
if (adminEnabled) { if (adminEnabled) {

View File

@ -8,6 +8,8 @@
import { emailNotificationService } from '../services/emailNotification.service'; import { emailNotificationService } from '../services/emailNotification.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
/** /**
* Simulate real workflow scenario * Simulate real workflow scenario
*/ */
@ -18,7 +20,7 @@ async function testRealScenario() {
// Mock user data (simulating real database records) // Mock user data (simulating real database records)
const user10 = { const user10 = {
userId: 'user-10-uuid', userId: 'user-10-uuid',
email: 'john.doe@royalenfield.com', email: `john.doe@${appDomain}`,
displayName: 'John Doe', displayName: 'John Doe',
department: 'Engineering', department: 'Engineering',
designation: 'Senior Engineer' designation: 'Senior Engineer'
@ -26,7 +28,7 @@ async function testRealScenario() {
const user12 = { const user12 = {
userId: 'user-12-uuid', userId: 'user-12-uuid',
email: 'jane.smith@royalenfield.com', email: `jane.smith@${appDomain}`,
displayName: 'Jane Smith', displayName: 'Jane Smith',
department: 'Management', department: 'Management',
designation: 'Engineering Manager', designation: 'Engineering Manager',
@ -52,7 +54,7 @@ async function testRealScenario() {
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head. This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
</blockquote> </blockquote>
<p>Please review and approve at your earliest convenience.</p> <p>Please review and approve at your earliest convenience.</p>
<p>For questions, contact: <a href="mailto:john.doe@royalenfield.com">john.doe@royalenfield.com</a></p> <p>For questions, contact: <a href="mailto:john.doe@${appDomain}">john.doe@${appDomain}</a></p>
`, `,
requestType: 'Purchase', requestType: 'Purchase',
priority: 'HIGH', priority: 'HIGH',
@ -68,21 +70,21 @@ async function testRealScenario() {
{ {
levelNumber: 1, levelNumber: 1,
approverName: 'Jane Smith', approverName: 'Jane Smith',
approverEmail: 'jane.smith@royalenfield.com', approverEmail: `jane.smith@${appDomain}`,
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
}, },
{ {
levelNumber: 2, levelNumber: 2,
approverName: 'Michael Brown', approverName: 'Michael Brown',
approverEmail: 'michael.brown@royalenfield.com', approverEmail: `michael.brown@${appDomain}`,
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
}, },
{ {
levelNumber: 3, levelNumber: 3,
approverName: 'Sarah Johnson', approverName: 'Sarah Johnson',
approverEmail: 'sarah.johnson@royalenfield.com', approverEmail: `sarah.johnson@${appDomain}`,
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
} }
@ -168,7 +170,7 @@ async function testRealScenario() {
approvedUser12, approvedUser12,
user10, user10,
false, // not final approval false, // not final approval
{ displayName: 'Michael Brown', email: 'michael.brown@royalenfield.com' } { displayName: 'Michael Brown', email: `michael.brown@${appDomain}` }
); );
console.log('\n'); console.log('\n');
@ -252,4 +254,3 @@ testRealScenario()
console.error('❌ Test failed:', error); console.error('❌ Test failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -3,6 +3,9 @@ import jwt from 'jsonwebtoken';
import { User } from '../models/User'; import { User } from '../models/User';
import { ssoConfig } from '../config/sso'; import { ssoConfig } from '../config/sso';
import { ResponseHandler } from '../utils/responseHandler'; import { ResponseHandler } from '../utils/responseHandler';
import { ApiTokenService } from '../services/apiToken.service';
const apiTokenService = new ApiTokenService();
interface JwtPayload { interface JwtPayload {
userId: string; userId: string;
@ -23,6 +26,29 @@ export const authenticateToken = async (
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN 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) // Fallback to cookie if available (requires cookie-parser middleware)
if (!token && req.cookies?.accessToken) { if (!token && req.cookies?.accessToken) {
token = req.cookies.accessToken; token = req.cookies.accessToken;

View File

@ -15,7 +15,7 @@ const getAllowedOrigins = (): string[] | boolean => {
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com'); console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
return []; return [];
} else { } else {
// Development fallback: allow localhost:3000 // Dev fallback: allow localhost:3000
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.'); 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'); console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
return ['http://localhost:3000']; return ['http://localhost:3000'];

View File

@ -0,0 +1,433 @@
/**
* Malware Scan Middleware
* Express middleware that intercepts file uploads, triggers ClamAV scan,
* and blocks infected files. Uses temp file approach to work with memory storage.
*
* Flow:
* multer (memory storage) malwareScanMiddleware controller
*
* Write buffer to temp file ClamAV scan Delete temp file
*
* Clean attach result to req, call next()
* Infected return 403
* Scan error return 503 (fail-secure)
* Skipped (disabled) log, call next()
*/
import { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { scanFile, ClamScanResult } from '../services/clamav/clamavScanWrapper';
import { scanContentForXSS, ContentScanResult } from '../services/fileUpload/contentXSSScanner';
import { logSecurityEvent, SecurityEventType } from '../services/logging/securityEventLogger';
import { validateFile } from '../services/fileUpload/fileValidationService';
// ── Extend Express Request ──
declare global {
namespace Express {
interface Request {
malwareScanResult?: ClamScanResult;
contentScanResult?: ContentScanResult;
scanEventId?: string;
}
}
}
// ── Temp file helpers ──
function writeTempFile(buffer: Buffer, originalName: string): string {
const tempDir = path.join(os.tmpdir(), 'clamav-scan');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const ext = path.extname(originalName);
const tempPath = path.join(tempDir, `${uuidv4()}${ext}`);
fs.writeFileSync(tempPath, buffer);
return tempPath;
}
function deleteTempFile(tempPath: string): void {
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (error) {
console.error('[MalwareScan] Failed to delete temp file:', tempPath, error);
}
}
// ── Middleware ──
/**
* Malware scan middleware for single file uploads (multer.single)
* Works with memory storage writes buffer to temp scans deletes temp
*/
export 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,
});
}
}

View File

@ -180,6 +180,58 @@ export const queueJobProcessingRate = new client.Gauge({
registers: [register], 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 // MIDDLEWARE
// ============================================================================ // ============================================================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,11 @@ interface ActivityTypeAttributes {
taxationType?: string; taxationType?: string;
sapRefNo?: string; sapRefNo?: string;
isActive: boolean; isActive: boolean;
hsnCode?: string | null;
sacCode?: string | null;
gstRate?: number | null;
glCode?: string | null;
creditNature?: 'Commercial' | 'GST' | null;
createdBy: string; createdBy: string;
updatedBy?: string; updatedBy?: string;
createdAt: Date; createdAt: Date;
@ -24,6 +29,11 @@ class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAtt
public taxationType?: string; public taxationType?: string;
public sapRefNo?: string; public sapRefNo?: string;
public isActive!: boolean; 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 createdBy!: string;
public updatedBy?: string; public updatedBy?: string;
public createdAt!: Date; public createdAt!: Date;
@ -71,6 +81,31 @@ ActivityType.init(
defaultValue: true, defaultValue: true,
field: 'is_active' 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: { createdBy: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,

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

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

View File

@ -29,6 +29,7 @@ interface ClaimBudgetTrackingAttributes {
ioBlockedAt?: Date; ioBlockedAt?: Date;
// Closed Expenses // Closed Expenses
closedExpenses?: number; closedExpenses?: number;
taxableClosedExpenses?: number;
closedExpensesSubmittedAt?: Date; closedExpensesSubmittedAt?: Date;
// Final Claim Amount // Final Claim Amount
finalClaimAmount?: number; finalClaimAmount?: number;
@ -50,7 +51,7 @@ interface ClaimBudgetTrackingAttributes {
updatedAt: Date; updatedAt: Date;
} }
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'> {} 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'> { }
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes { class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
public budgetId!: string; public budgetId!: string;
@ -64,6 +65,7 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
public ioBlockedAmount?: number; public ioBlockedAmount?: number;
public ioBlockedAt?: Date; public ioBlockedAt?: Date;
public closedExpenses?: number; public closedExpenses?: number;
public taxableClosedExpenses?: number;
public closedExpensesSubmittedAt?: Date; public closedExpensesSubmittedAt?: Date;
public finalClaimAmount?: number; public finalClaimAmount?: number;
public finalClaimAmountApprovedAt?: Date; public finalClaimAmountApprovedAt?: Date;
@ -159,6 +161,11 @@ ClaimBudgetTracking.init(
allowNull: true, allowNull: true,
field: 'closed_expenses_submitted_at' field: 'closed_expenses_submitted_at'
}, },
taxableClosedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'taxable_closed_expenses'
},
finalClaimAmount: { finalClaimAmount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, allowNull: true,

View File

@ -9,7 +9,20 @@ interface ClaimCreditNoteAttributes {
invoiceId?: string; invoiceId?: string;
creditNoteNumber?: string; creditNoteNumber?: string;
creditNoteDate?: Date; creditNoteDate?: Date;
creditNoteAmount?: number; 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;
sapDocumentNumber?: string; sapDocumentNumber?: string;
creditNoteFilePath?: string; creditNoteFilePath?: string;
status?: string; status?: string;
@ -22,7 +35,7 @@ interface ClaimCreditNoteAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {} 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'> { }
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes { class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string; public creditNoteId!: string;
@ -30,7 +43,20 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
public invoiceId?: string; public invoiceId?: string;
public creditNoteNumber?: string; public creditNoteNumber?: string;
public creditNoteDate?: Date; public creditNoteDate?: Date;
public creditNoteAmount?: number; 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 sapDocumentNumber?: string; public sapDocumentNumber?: string;
public creditNoteFilePath?: string; public creditNoteFilePath?: string;
public status?: string; public status?: string;
@ -86,8 +112,73 @@ ClaimCreditNote.init(
}, },
creditNoteAmount: { creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'credit_amount'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true, allowNull: true,
field: 'credit_amount', 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'
}, },
sapDocumentNumber: { sapDocumentNumber: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),

View File

@ -6,11 +6,38 @@ interface ClaimInvoiceAttributes {
invoiceId: string; invoiceId: string;
requestId: string; requestId: string;
invoiceNumber?: string; invoiceNumber?: string;
invoiceDate?: Date;
amount?: number;
dmsNumber?: string; dmsNumber?: string;
invoiceFilePath?: string; invoiceDate?: Date;
status?: string; 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;
errorMessage?: string; errorMessage?: string;
generatedAt?: Date; generatedAt?: Date;
description?: string; description?: string;
@ -18,17 +45,44 @@ interface ClaimInvoiceAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {} 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'> { }
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes { class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
public invoiceId!: string; public invoiceId!: string;
public requestId!: string; public requestId!: string;
public invoiceNumber?: string; public invoiceNumber?: string;
public invoiceDate?: Date;
public amount?: number;
public dmsNumber?: string; public dmsNumber?: string;
public invoiceFilePath?: string; public invoiceDate?: Date;
public status?: string; 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 errorMessage?: string; public errorMessage?: string;
public generatedAt?: Date; public generatedAt?: Date;
public description?: string; public description?: string;
@ -61,6 +115,11 @@ ClaimInvoice.init(
allowNull: true, allowNull: true,
field: 'invoice_number', field: 'invoice_number',
}, },
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number',
},
invoiceDate: { invoiceDate: {
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
allowNull: true, allowNull: true,
@ -68,23 +127,153 @@ ClaimInvoice.init(
}, },
amount: { amount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: true, allowNull: false,
field: 'invoice_amount', 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: { status: {
type: DataTypes.STRING(50), 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, allowNull: true,
field: 'generation_status', 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'
}, },
errorMessage: { errorMessage: {
type: DataTypes.TEXT, type: DataTypes.TEXT,

View File

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

View File

@ -11,16 +11,17 @@ interface DealerClaimDetailsAttributes {
dealerName: string; dealerName: string;
dealerEmail?: string; dealerEmail?: string;
dealerPhone?: string; dealerPhone?: string;
dealerAddress?: string; dealerAddress?: string | null;
activityDate?: Date; activityDate?: Date | null;
location?: string; location?: string;
periodStartDate?: Date; periodStartDate?: Date;
periodEndDate?: Date; periodEndDate?: Date;
totalProposedTaxableAmount?: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {} interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { }
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes { class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
public claimId!: string; public claimId!: string;
@ -31,11 +32,12 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
public dealerName!: string; public dealerName!: string;
public dealerEmail?: string; public dealerEmail?: string;
public dealerPhone?: string; public dealerPhone?: string;
public dealerAddress?: string; public dealerAddress?: string | null;
public activityDate?: Date; public activityDate?: Date | null;
public location?: string; public location?: string;
public periodStartDate?: Date; public periodStartDate?: Date;
public periodEndDate?: Date; public periodEndDate?: Date;
public totalProposedTaxableAmount?: number;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
@ -93,8 +95,8 @@ DealerClaimDetails.init(
}, },
dealerAddress: { dealerAddress: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: true, field: 'dealer_address',
field: 'dealer_address' comment: 'Dealer address'
}, },
activityDate: { activityDate: {
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
@ -115,6 +117,12 @@ DealerClaimDetails.init(
allowNull: true, allowNull: true,
field: 'period_end_date' 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: { createdAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@ -9,6 +9,23 @@ interface DealerCompletionExpenseAttributes {
completionId?: string | null; completionId?: string | null;
description: string; description: string;
amount: number; 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; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -21,6 +38,23 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
public completionId?: string | null; public completionId?: string | null;
public description!: string; public description!: string;
public amount!: number; 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 createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
} }
@ -63,6 +97,93 @@ DealerCompletionExpense.init(
allowNull: false, allowNull: false,
field: 'amount', 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: { createdAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@ -9,6 +9,22 @@ interface DealerProposalCostItemAttributes {
requestId: string; requestId: string;
itemDescription: string; itemDescription: string;
amount: number; 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; itemOrder: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@ -22,6 +38,22 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public requestId!: string; public requestId!: string;
public itemDescription!: string; public itemDescription!: string;
public amount!: number; 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 itemOrder!: number;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
@ -66,6 +98,88 @@ DealerProposalCostItem.init(
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: false 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: { itemOrder: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,

View File

@ -137,7 +137,7 @@ InternalOrder.init(
indexes: [ indexes: [
{ {
fields: ['request_id'], fields: ['request_id'],
unique: true unique: false
}, },
{ {
fields: ['io_number'] fields: ['io_number']

View File

@ -1,5 +1,8 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database'; 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 * User Role Enum

View File

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

View File

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

View File

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

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