Compare commits

..

No commits in common. "8753c9477dda3c558b4678bb2089775a0df843b1" and "798688e4c2523270dce6518e73da9db32cc67b15" have entirely different histories.

172 changed files with 2327 additions and 9789 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@{{APP_DOMAIN}} SMTP_USER=notifications@royalenfield.com
SMTP_PASSWORD=your_smtp_password SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
# AI Service (for conclusion generation) # AI 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. Done: Test TAT breach alerts 5. **TODO:** 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. Done: Test with various date ranges 4. **TODO:** 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:
{{VAPID_PUBLIC_KEY}} BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
Private Key: Private Key:
{{VAPID_PRIVATE_KEY}} aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
======================================= =======================================
``` ```
@ -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={{VAPID_PUBLIC_KEY}} VAPID_PUBLIC_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
VAPID_PRIVATE_KEY={{VAPID_PRIVATE_KEY}} VAPID_PRIVATE_KEY=aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
VAPID_CONTACT=mailto:{{ADMIN_EMAIL}} VAPID_CONTACT=mailto:admin@royalenfield.com
``` ```
**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={{VAPID_PUBLIC_KEY}} VITE_PUBLIC_VAPID_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
``` ```
**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@{{APP_DOMAIN}}'; UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@royalenfield.com';
[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@{{APP_DOMAIN}}'; WHERE email = 'your-email@royalenfield.com';
\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@{{APP_DOMAIN}} VAPID_CONTACT=mailto:admin@royalenfield.com
``` ```
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 +1,2 @@
import{a as s}from"./index-hYhqmPqT.js";import"./radix-vendor-GwO0o3Qg.js";import"./charts-vendor-waDbLeao.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-3qilyUHW.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-Bmv9jJki.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion}; import{a as s}from"./index-BgkDE8Pi.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DyksGUTu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-VENY18zj.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"conclusionApi-VENY18zj.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}

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

@ -1,31 +1,31 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" /> <link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" <meta name="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" /> content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<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 essential fonts and icons --> <!-- Preload critical 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-hYhqmPqT.js"></script> <script type="module" crossorigin src="/assets/index-BgkDE8Pi.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/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-Bmv9jJki.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css"> <link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View File

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

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://reflow.royalenfield.com</loc>
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@ -1,8 +1,39 @@
# docker-compose.full.yml # =============================================================================
# Synced with streamlined infrastructure # RE Workflow - Full Stack Docker Compose
# Includes: Application + Database + Monitoring Stack
# =============================================================================
# Usage:
# docker-compose -f docker-compose.full.yml up -d
# =============================================================================
version: '3.8' 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
@ -19,24 +50,70 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
clamav: backend:
image: clamav/clamav:latest build:
container_name: re_clamav context: .
ports: dockerfile: Dockerfile
- "3310:3310" container_name: re_workflow_backend
volumes:
- clamav_data:/var/lib/clamav
environment: environment:
- CLAMAV_NO_FRESHCLAMD=false NODE_ENV: development
healthcheck: DB_HOST: postgres
test: ["CMD", "clamdcheck"] DB_PORT: 5432
interval: 60s DB_USER: ${DB_USER:-laxman}
timeout: 10s DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
retries: 5 DB_NAME: ${DB_NAME:-re_workflow_db}
start_period: 120s REDIS_URL: redis://redis:6379
restart: unless-stopped PORT: 5000
# Loki for logging
LOKI_HOST: http://loki:3100
ports:
- "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
networks: 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
@ -79,12 +156,15 @@ 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
@ -95,13 +175,54 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
volumes: node-exporter:
redis_data: image: prom/node-exporter:v1.6.1
clamav_data: container_name: re_node_exporter
loki_data: ports:
promtail_data: - "9100:9100"
grafana_data: networks:
- re_workflow_network
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.26.0
container_name: re_alertmanager
ports:
- "9093:9093"
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
networks:
- re_workflow_network
restart: unless-stopped
# ===========================================================================
# NETWORKS
# ===========================================================================
networks: 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,8 +1,28 @@
# 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
@ -19,88 +39,43 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
clamav: backend:
image: clamav/clamav:latest build:
container_name: re_clamav context: .
ports: dockerfile: Dockerfile
- "3310:3310" container_name: re_workflow_backend
volumes:
- clamav_data:/var/lib/clamav
environment: environment:
- CLAMAV_NO_FRESHCLAMD=false NODE_ENV: development
healthcheck: DB_HOST: postgres
test: ["CMD", "clamdcheck"] DB_PORT: 5432
interval: 60s DB_USER: ${DB_USER:-laxman}
timeout: 10s DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
retries: 5 DB_NAME: ${DB_NAME:-re_workflow_db}
start_period: 120s REDIS_URL: redis://redis:6379
restart: unless-stopped PORT: 5000
networks:
- re_workflow_network
loki:
image: grafana/loki:2.9.2
container_name: re_loki
ports: ports:
- "3100:3100" - "5000:5000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes: volumes:
- ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro - ./logs:/app/logs
- loki_data:/loki - ./uploads:/app/uploads
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", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
interval: 30s
timeout: 10s
retries: 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

@ -1,71 +0,0 @@
# Dealer Claim Financial Settlement Workflow
This document outlines the workflow for financial settlement of dealer claims within the Royal Enfield platform, following the transition from direct DMS integration to an Azure File Storage (AFS) based data exchange with SAP.
## Workflow Overview
The financial settlement process ensures that dealer claims are legally documented and financially settled through Royal Enfield's SAP system.
### 1. Legal Compliance: PWC E-Invoicing
Once the **Dealer Completion Documents** are submitted and approved by the **Initiator (Requestor Evaluation)**, the system triggers the legal compliance step.
- **Service**: `PWCIntegrationService`
- **Action**: Generates a signed E-Invoice via PWC API.
- **Output**: IRN (Invoice Reference Number), Ack No, Ack Date, Signed Invoice (PDF/B64), and QR Code.
- **Purpose**: Ensures the claim is legally recognized under GST regulations.
### 2. Financial Posting: AFS/CSV Integration
The financial settlement is handled by exchanging data files with SAP via **Azure File Storage (AFS)**.
- **Action**: The system generates a **CSV file** containing the following details:
- Invoice Number (from PWC)
- Invoice Amount (with/without GST as per activity type)
- GL Code (Resolved based on Activity Type/IO)
- Internal Order (IO) Number
- Dealer Code
- **Storage**: CSV is uploaded to a designated folder in AFS.
- **SAP Role**: SAP periodically polls AFS, picks up the CSV, and posts the transaction internally.
### 3. Payment Outcome: Credit Note
The result of the financial posting in SAP is a **Credit Note**.
- **Workflow**:
- SAP generates a Credit Note and uploads it back to AFS.
- RE Backend monitors the AFS folder.
- Once a Credit Note is detected, the system retrieves it and attaches it to the workflow request.
- An email notification (using `creditNoteSent.template.ts`) is sent to the dealer.
## Sequence Diagram
```mermaid
sequenceDiagram
participant Dealer
participant Backend
participant PWC
participant AFS as Azure File Storage
participant SAP
Dealer->>Backend: Submit Completion Docs (Actuals)
Backend->>Backend: Initiator Approval
Backend->>PWC: Generate Signed E-Invoice
PWC-->>Backend: Return IRN & QR Code
Backend->>Backend: Generate Settlement CSV
Backend->>AFS: Upload CSV
SAP->>AFS: Pick up CSV
SAP->>SAP: Post Financials
SAP->>AFS: Upload Credit Note
Backend->>AFS: Poll/Retrieve Credit Note
Backend->>Dealer: Send Credit Note Notification
```
## GL Code Resolution
The GL Code is solved dynamically based on:
1. **Activity Type**: Each activity (e.g., Marketing Event, Demo) has a primary GL mapping.
2. **Internal Order (IO)**: If specific IO logic is required, the GL can be overridden.
## Summary of Integration Points
| Component | Integration Type | Responsibility |
| :--- | :--- | :--- |
| **PWC** | REST API | Legal E-Invoice |
| **AFS (Azure)** | File Storage SDK | CSV Exchange |
| **SAP** | Batch Processing | Financial Posting & Credit Note |

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@{{APP_DOMAIN}}`) - **Mapping**: System user (`system@royalenfield.com`)
- **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@{{APP_DOMAIN}}`) - **Mapping**: System user (`system@royalenfield.com`)
- **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@{{APP_DOMAIN}}) 4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
``` ```
## 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@{{APP_DOMAIN}}) | | `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) |
| `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.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386 5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.royalenfield.com,,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
``` ```
**What gets auto-generated:** **What gets auto-generated:**

View File

@ -1,29 +0,0 @@
# Dealer Integration Implementation Status
This document summarizes the changes made to integrate the external Royal Enfield Dealer API and implement the dealer validation logic during request creation.
## Completed Work
### 1. External Dealer API Integration
- **Service**: `DealerExternalService` in `src/services/dealerExternal.service.ts`
- Implemented `getDealerByCode` to fetch data from `https://api-uat2.royalenfield.com/DealerMaster`.
- Returns real-time GSTIN, Address, and location details.
- **Controller & Routes**: Integrated under `/api/v1/dealers-external/search/:dealerCode`.
- **Enrichment**: `DealerService.getDealerByCode` now automatically merges this external data into the system's `DealerInfo`, benefiting PWC and PDF generation services.
### 2. Dealer Validation & Field Mapping Fix
- **Strategic Mapping**: Based on requirement, all dealer codes are now mapped against the `employeeNumber` field (HR ID) in the `User` model, not `employeeId`.
- **User Enrichment Service**: `validateDealerUser(dealerCode)` now searches by `employeeNumber`.
- **SSO Alignment**: `AuthService.ts` now extracts `dealer_code` from the authentication response and persists it to `employeeNumber`.
- **Dealer Service**: `getDealerByCode` uses jobTitle-based validation against the `User` table as the primary lookup.
### 3. Claim Workflow Integration
- **Dealer Claim Service**: `createClaimRequest` validates the dealer immediately and overrides approver steps 1 and 4 with the validated user.
- **Workflow Controller**: Enforces dealer validation for all `DEALER CLAIM` templates and any request containing a `dealerCode`.
### 4. E-Invoice & PDF Alignment
- **PWC Integration**: `generateSignedInvoice` now uses the enriched `DealerInfo` containing the correct external GSTIN and state code.
- **Invoice PDF**: `PdfService` correctly displays the external dealer name, GSTIN, and POS from the source of truth.
## Conclusion
All integrated components have been verified via test scripts and end-to-end flow analysis. The dependency on the local `dealers` table has been successfully minimized, and the system now relies on the `User` table and External API as the primary sources of dealer information.

View File

@ -56,7 +56,7 @@ users {
```json ```json
{ {
"userId": "uuid-1", "userId": "uuid-1",
"email": "john.doe@{{APP_DOMAIN}}", "email": "john.doe@royalenfield.com",
"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@{{APP_DOMAIN}}", "email": "test.2@royalenfield.com",
"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.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` - UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice`
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` - Production: `https://reflow.royalenfield.com/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.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` - UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note`
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` - Production: `https://reflow.royalenfield.com/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.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` | | UAT | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` |
| Production | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow.{{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` |
--- ---

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@{{APP_DOMAIN}} # Change: YOUR_EMAIL@royalenfield.com
# 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@{{APP_DOMAIN}}'; WHERE email = 'your-email@royalenfield.com';
-- 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@{{APP_DOMAIN}}';" psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@royalenfield.com';"
``` ```
--- ---
@ -241,13 +241,13 @@ Expected output:
```sql ```sql
-- Single user -- Single user
UPDATE users SET role = 'MANAGEMENT' UPDATE users SET role = 'MANAGEMENT'
WHERE email = 'manager@{{APP_DOMAIN}}'; WHERE email = 'manager@royalenfield.com';
-- Multiple users -- Multiple users
UPDATE users SET role = 'MANAGEMENT' UPDATE users SET role = 'MANAGEMENT'
WHERE email IN ( WHERE email IN (
'manager1@{{APP_DOMAIN}}', 'manager1@royalenfield.com',
'manager2@{{APP_DOMAIN}}' 'manager2@royalenfield.com'
); );
-- 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@{{APP_DOMAIN}}'; WHERE email = 'admin@royalenfield.com';
-- Multiple admins -- Multiple admins
UPDATE users SET role = 'ADMIN' UPDATE users SET role = 'ADMIN'
WHERE email IN ( WHERE email IN (
'admin1@{{APP_DOMAIN}}', 'admin1@royalenfield.com',
'admin2@{{APP_DOMAIN}}' 'admin2@royalenfield.com'
); );
-- 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@{{APP_DOMAIN}}'; WHERE email = 'your-email@royalenfield.com';
``` ```
--- ---
@ -344,7 +344,7 @@ WHERE email = 'your-email@{{APP_DOMAIN}}';
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \ 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@{{APP_DOMAIN}}", "email": "test@royalenfield.com",
"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@{{APP_DOMAIN}}'; SELECT email, role FROM users WHERE email = 'test@royalenfield.com';
-- Expected: role = 'USER' -- Expected: role = 'USER'
``` ```
### 3. Update to ADMIN ### 3. Update to ADMIN
```sql ```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}'; UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
``` ```
### 4. Verify API Access ### 4. Verify API Access
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
# 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@{{APP_DOMAIN}}", ...}' -d '{"email": "test@royalenfield.com", ...}'
# 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@{{APP_DOMAIN}}'; SELECT * FROM users WHERE email = 'your-email@royalenfield.com';
-- 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@{{APP_DOMAIN}}'; SELECT email, role, is_active FROM users WHERE email = 'your-email@royalenfield.com';
-- 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.{{APP_DOMAIN}}` | | **Domain** | `https://reflow.royalenfield.com` |
| **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.{{APP_DOMAIN}}", "https://reflow.royalenfield.com",
"https://www.{{APP_DOMAIN}}" "https://www.royalenfield.com"
], ],
"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.{{APP_DOMAIN}} | | **Domain** | https://reflow-uat.royalenfield.com |
| **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.{{APP_DOMAIN}}", "https://reflow-uat.royalenfield.com",
"https://reflow.{{APP_DOMAIN}}" "https://reflow.royalenfield.com"
], ],
"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@{{APP_DOMAIN}}", "login": "sanjaysahu@Royalenfield.com",
"email": "sanjaysahu@{{APP_DOMAIN}}" "email": "sanjaysahu@royalenfield.com"
}, },
... ...
} }
@ -127,7 +127,7 @@ Example log:
### Test with curl ### Test with curl
```bash ```bash
curl --location 'https://{{IDP_DOMAIN}}/api/v1/users/testuser10@eichergroup.com' \ curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \ --header '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@{{APP_DOMAIN}} | true | | admin@royalenfield.com | true |
| user1@{{APP_DOMAIN}} | false | | user1@royalenfield.com | false |
+-------------------------+-----------+ +-------------------------+-----------+
After Migration: After Migration:
+-------------------------+-----------+-----------+ +-------------------------+-----------+-----------+
| email | role | is_admin | | email | role | is_admin |
+-------------------------+-----------+-----------+ +-------------------------+-----------+-----------+
| admin@{{APP_DOMAIN}} | ADMIN | true | | admin@royalenfield.com | ADMIN | true |
| user1@{{APP_DOMAIN}} | USER | false | | user1@royalenfield.com | 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@{{APP_DOMAIN}}'; WHERE email = 'manager@royalenfield.com';
-- 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@{{APP_DOMAIN}}'; WHERE email = 'admin@royalenfield.com';
-- 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@{{APP_DOMAIN}}'; WHERE email = 'user@royalenfield.com';
``` ```
### 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@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}'); WHERE email IN ('admin@royalenfield.com', 'it.admin@royalenfield.com');
-- 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@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}'); WHERE email IN ('manager@royalenfield.com', 'auditor@royalenfield.com');
-- 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@{{APP_DOMAIN}}'; WHERE email = 'your-email@royalenfield.com';
``` ```
### 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@{{APP_DOMAIN}}'; WHERE email = 'your-email@royalenfield.com';
``` ```
--- ---

View File

@ -314,7 +314,7 @@ JWT_EXPIRY=24h
REFRESH_TOKEN_EXPIRY=7d REFRESH_TOKEN_EXPIRY=7d
# Okta Configuration # Okta Configuration
OKTA_DOMAIN=https://{{IDP_DOMAIN}} OKTA_DOMAIN=https://dev-830839.oktapreview.com
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://{{IDP_DOMAIN}}` - **Default**: `https://dev-830839.oktapreview.com`
- **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@{{APP_DOMAIN}}", "email": "user@royalenfield.com",
"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://{{IDP_DOMAIN}} OKTA_DOMAIN=https://dev-830839.oktapreview.com
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://{{IDP_DOMAIN}} VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
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@${appDomain}`, approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com',
} }
``` ```

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@{{APP_DOMAIN}}", "username": "john.doe@royalenfield.com",
"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={{GCP_PROJECT_ID}} GCP_PROJECT_ID=re-workflow-project
GCP_BUCKET_NAME={{GCP_BUCKET_NAME}} GCP_BUCKET_NAME=re-workflow-documents
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@{{APP_DOMAIN}} SMTP_USER=notifications@royalenfield.com
SMTP_PASSWORD=your_smtp_password SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
# AI Service (for conclusion generation) - Vertex AI Gemini # 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={{APP_VERSION}} APP_VERSION=1.2.0
# ============ 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 # 15 minutes RATE_LIMIT_WINDOW_MS=900000
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}} VAPID_PUBLIC_KEY={{vapid_public_key}} note: same key need to add on front end for web push
VAPID_PRIVATE_KEY={{vapid_private_key}} VAPID_PRIVATE_KEY={{vapid_private_key}}
VAPID_CONTACT=mailto:you@example.com VAPID_CONTACT=mailto:you@example.com
#Redis #Redis
REDIS_URL={{REDIS_URL}} REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
TAT_TEST_MODE=false # Set to true to accelerate TAT for testing TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
# SAP Integration (OData Service via Zscaler) # SAP Integration (OData Service via Zscaler)
SAP_BASE_URL=https://{{SAP_DOMAIN_HERE}}:{{PORT}} SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
SAP_USERNAME={{SAP_USERNAME}} SAP_USERNAME={{SAP_USERNAME}}
SAP_PASSWORD={{SAP_PASSWORD}} SAP_PASSWORD={{SAP_PASSWORD}}
SAP_TIMEOUT_MS=30000 SAP_TIMEOUT_MS=30000

View File

@ -52,8 +52,6 @@ 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 install && npm run build && npm run setup && npm run start:prod", "start": "npm run build && npm run start:prod && npm run setup",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev": "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,7 +30,6 @@
"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",
@ -51,8 +50,6 @@
"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",
@ -75,7 +72,6 @@
"@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@{{APP_DOMAIN}}' -- ← CHANGE THIS WHERE email = 'YOUR_EMAIL@royalenfield.com' -- ← 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@{{APP_DOMAIN}}', 'admin@royalenfield.com',
'it.admin@{{APP_DOMAIN}}', 'it.admin@royalenfield.com',
'system.admin@{{APP_DOMAIN}}' 'system.admin@royalenfield.com'
-- 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@{{APP_DOMAIN}}', 'manager1@royalenfield.com',
'dept.head@{{APP_DOMAIN}}', 'dept.head@royalenfield.com',
'auditor@{{APP_DOMAIN}}' 'auditor@royalenfield.com'
-- 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@{{APP_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
# 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@{{APP_DOMAIN}}" echo " Example: mailto:admin@royalenfield.com"
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,14 +7,10 @@ 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
@ -27,7 +23,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 === 'development' || process.env.NODE_ENV === 'local'; const isDev = process.env.NODE_ENV !== 'production';
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
@ -40,10 +36,8 @@ 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
//: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance // CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
// even if the header is truncated in certain response types (like 301 redirects). // even if the header is truncated in certain response types (like 301 redirects).
const directives = [ const directives = [
"frame-ancestors 'self'", "frame-ancestors 'self'",
@ -51,13 +45,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=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='", "style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='",
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' '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-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://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`, "img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com",
"frame-src 'self' blob: data:", "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'",
@ -91,17 +85,18 @@ app.use(cookieParser());
const userService = new UserService(); const userService = new UserService();
// Initializer for database connection (called from server.ts) // Initialize database connection
export const initializeAppDatabase = async () => { const initializeDatabase = async () => {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
console.log('✅ App database connection established');
} catch (error) { } catch (error) {
console.error('❌ App database connection failed:', error); console.error('❌ 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
@ -116,12 +111,6 @@ 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'));
@ -129,7 +118,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('/metrics', authenticateToken, requireAdmin, createMetricsRouter()); app.use(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) => {
@ -146,16 +135,7 @@ app.use('/api/v1', routes);
// Serve uploaded files statically // Serve uploaded files statically
ensureUploadDir(); ensureUploadDir();
app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR)); app.use('/uploads', 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> => {
@ -209,7 +189,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', authenticateToken, requireAdmin, async (_req: express.Request, res: express.Response): Promise<void> => { app.get('/api/v1/users', async (_req: express.Request, res: express.Response): Promise<void> => {
try { try {
const users = await userService.getAllUsers(); const users = await userService.getAllUsers();
@ -314,4 +294,4 @@ if (reactBuildPath && fs.existsSync(path.join(reactBuildPath, "index.html"))) {
}); });
} }
export default app; export default app;

View File

@ -8,9 +8,9 @@ export const emailConfig = {
pass: process.env.SMTP_PASSWORD || '', pass: process.env.SMTP_PASSWORD || '',
}, },
}, },
from: process.env.EMAIL_FROM || `RE Workflow System <notifications@${process.env.APP_DOMAIN || 'royalenfield.com'}>`, from: process.env.EMAIL_FROM || 'RE Workflow System <notifications@royalenfield.com>',
// Email templates // Email templates
templates: { templates: {
workflowCreated: 'workflow-created', workflowCreated: 'workflow-created',
@ -20,7 +20,7 @@ export const emailConfig = {
tatReminder: 'tat-reminder', tatReminder: 'tat-reminder',
tatBreached: 'tat-breached', tatBreached: 'tat-breached',
}, },
// Email settings // Email settings
settings: { settings: {
retryAttempts: 3, retryAttempts: 3,

View File

@ -8,18 +8,18 @@ const ssoConfig: SSOConfig = {
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; }, get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
get sessionSecret() { return process.env.SESSION_SECRET || ''; }, get sessionSecret() { return process.env.SESSION_SECRET || ''; },
// Use only FRONTEND_URL from environment - no fallbacks // Use only FRONTEND_URL from environment - no fallbacks
get allowedOrigins() { get allowedOrigins() {
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 || `{{IDP_DOMAIN}}`; }, get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; },
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 || `{{IDP_DOMAIN}}/realms/RE`; }, get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; },
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; }, get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || `{{TANFLOW_CLIENT_SECRET}}`; }, get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; },
}; };
export { ssoConfig }; export { ssoConfig };

View File

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

View File

@ -132,13 +132,10 @@ 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: isSecureEnv, secure: isProduction,
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain sameSite: isProduction ? '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
}; };
@ -151,7 +148,7 @@ export class AuthController {
message: 'Token refreshed successfully' message: 'Token refreshed successfully'
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
} else { } else {
// Dev: Include token for debugging // Development: Include token for debugging
ResponseHandler.success(res, { ResponseHandler.success(res, {
accessToken: newAccessToken accessToken: newAccessToken
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
@ -209,13 +206,10 @@ 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: isSecureEnv, secure: isProduction,
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const), sameSite: isProduction ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/', path: '/',
}; };
@ -262,13 +256,10 @@ 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: isSecureEnv, secure: isProduction,
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const), sameSite: isProduction ? ('lax' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,
path: '/', path: '/',
}; };
@ -302,16 +293,13 @@ 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: isSecureEnv, secure: isProduction,
sameSite: 'lax' as const, sameSite: 'lax' as const,
path: '/', path: '/',
}; };
@ -481,13 +469,10 @@ 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: isSecureEnv, secure: isProduction,
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, sameSite: isProduction ? 'lax' as const : 'lax' as const,
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}; };
@ -564,13 +549,10 @@ 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: isSecureEnv, secure: isProduction,
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain sameSite: isProduction ? '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
}; };
@ -602,7 +584,7 @@ export class AuthController {
idToken: result.oktaIdToken idToken: result.oktaIdToken
}, 'Token exchange successful'); }, 'Token exchange successful');
} else { } else {
// Dev: Include tokens for debugging and different-port setup // Development: 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: finalRemark, finalRemark: sanitizedRemark,
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' });
} }
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS) const sanitizedRemark = sanitizeHtml(finalRemark);
// 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: finalRemark, finalRemark: sanitizedRemark,
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: finalRemark, finalRemark: sanitizedRemark,
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: finalRemark, conclusionRemark: sanitizedRemark,
closureDate: new Date() closureDate: new Date()
} as any); } as any);

View File

@ -2,7 +2,6 @@ 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';
@ -12,11 +11,6 @@ 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();
@ -81,7 +75,7 @@ export class DealerClaimController {
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message }); logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
return ResponseHandler.error(res, error.message, 400); return ResponseHandler.error(res, error.message, 400);
} }
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error creating claim request:', error); logger.error('[DealerClaimController] Error creating claim request:', error);
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage); return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
@ -307,7 +301,7 @@ export class DealerClaimController {
try { try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({ const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer, buffer: fileBuffer,
originalName: file.originalname, originalName: file.originalname,
@ -366,7 +360,7 @@ export class DealerClaimController {
try { try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({ const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer, buffer: fileBuffer,
originalName: file.originalname, originalName: file.originalname,
@ -426,7 +420,7 @@ export class DealerClaimController {
try { try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({ const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer, buffer: fileBuffer,
originalName: file.originalname, originalName: file.originalname,
@ -486,7 +480,7 @@ export class DealerClaimController {
try { try {
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from('')); const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({ const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer, buffer: fileBuffer,
originalName: attendanceSheetFile.originalname, originalName: attendanceSheetFile.originalname,
@ -567,18 +561,18 @@ export class DealerClaimController {
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> { async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { ioNumber } = req.query; const { ioNumber } = req.query;
if (!ioNumber || typeof ioNumber !== 'string') { if (!ioNumber || typeof ioNumber !== 'string') {
return ResponseHandler.error(res, 'IO number is required', 400); return ResponseHandler.error(res, 'IO number is required', 400);
} }
// Fetch IO details from SAP (will return mock data until SAP is integrated) // Fetch IO details from SAP (will return mock data until SAP is integrated)
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim()); const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
if (!ioValidation.isValid) { if (!ioValidation.isValid) {
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400); return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
} }
return ResponseHandler.success(res, { return ResponseHandler.success(res, {
ioNumber: ioValidation.ioNumber, ioNumber: ioValidation.ioNumber,
availableBalance: ioValidation.availableBalance, availableBalance: ioValidation.availableBalance,
@ -629,7 +623,7 @@ export class DealerClaimController {
} }
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0; const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
// Log received data for debugging // Log received data for debugging
logger.info('[DealerClaimController] updateIODetails received:', { logger.info('[DealerClaimController] updateIODetails received:', {
requestId, requestId,
@ -639,7 +633,7 @@ export class DealerClaimController {
receivedBlockedAmount: blockedAmount, // Original value from request receivedBlockedAmount: blockedAmount, // Original value from request
userId, userId,
}); });
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval) // Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
if (blockAmount > 0) { if (blockAmount > 0) {
if (availableBalance === undefined) { if (availableBalance === undefined) {
@ -655,9 +649,9 @@ export class DealerClaimController {
blockedAmount: blockAmount, blockedAmount: blockAmount,
// remainingBalance will be calculated by the service from SAP's response // remainingBalance will be calculated by the service from SAP's response
}; };
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData); logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
await this.dealerClaimService.updateIODetails( await this.dealerClaimService.updateIODetails(
requestId, requestId,
ioData, ioData,
@ -666,7 +660,7 @@ export class DealerClaimController {
// Fetch and return the updated IO details from database // Fetch and return the updated IO details from database
const updatedIO = await InternalOrder.findOne({ where: { requestId } }); const updatedIO = await InternalOrder.findOne({ where: { requestId } });
if (updatedIO) { if (updatedIO) {
return ResponseHandler.success(res, { return ResponseHandler.success(res, {
message: 'IO blocked successfully in SAP', message: 'IO blocked successfully in SAP',
@ -757,66 +751,7 @@ 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);
} }
} }
@ -940,7 +875,7 @@ export class DealerClaimController {
// First validate IO number // First validate IO number
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber); const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
if (!ioValidation.isValid) { if (!ioValidation.isValid) {
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400); return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
} }
@ -988,98 +923,5 @@ 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

@ -1,34 +0,0 @@
import { Request, Response } from 'express';
import { dealerExternalService } from '../services/dealerExternal.service';
import { ResponseHandler } from '../utils/responseHandler';
import logger from '../utils/logger';
export class DealerExternalController {
/**
* Search dealer by code via external API
* GET /api/v1/dealers-external/search/:dealerCode
*/
async searchByDealerCode(req: Request, res: Response): Promise<void> {
try {
const { dealerCode } = req.params;
if (!dealerCode) {
return ResponseHandler.error(res, 'Dealer code is required', 400);
}
const dealerInfo = await dealerExternalService.getDealerByCode(dealerCode);
if (!dealerInfo) {
return ResponseHandler.error(res, 'Dealer not found in external system', 404);
}
return ResponseHandler.success(res, dealerInfo, 'Dealer found successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[DealerExternalController] Error searching dealer ${req.params.dealerCode}:`, error);
return ResponseHandler.error(res, 'Failed to fetch dealer from external source', 500, errorMessage);
}
}
}
export const dealerExternalController = new DealerExternalController();

View File

@ -10,37 +10,13 @@ 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, source); const users = await this.userService.searchUsers(q, limit, currentUserId);
const result = users.map(u => ({ const result = users.map(u => ({
userId: (u as any).userId, userId: (u as any).userId,
@ -93,31 +69,6 @@ export class UserController {
} }
} }
async getUserById(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const user = await this.userService.getUserById(userId);
if (!user) {
ResponseHandler.error(res, 'User not found', 404);
return;
}
ResponseHandler.success(res, {
userId: user.userId,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department,
isActive: user.isActive
}, 'User fetched');
} catch (error) {
logger.error('Failed to fetch user by ID', { error });
ResponseHandler.error(res, 'Failed to fetch user by ID', 500);
}
}
/** /**
* Ensure user exists in database (create if not exists) * 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, validateDealerUser } from '@services/userEnrichment.service'; import { enrichApprovalLevels, enrichSpectators, validateInitiator } 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,15 +27,6 @@ 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) {
@ -179,15 +170,6 @@ 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**: Required implementation - **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
- **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**:
- Planned for email implementation - Currently has TODO comment 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)
- Planned for implementation - Currently has TODO comment
--- ---
@ -255,7 +255,7 @@ This document outlines all email templates required for the Dealer Claim Managem
### High Priority (Critical for Workflow) ### 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 3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
### 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@{{APP_DOMAIN}} SMTP_USER=notifications@royalenfield.com
SMTP_PASSWORD=your-app-specific-password SMTP_PASSWORD=your-app-specific-password
EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}> EMAIL_FROM=RE Flow <noreply@royalenfield.com>
# 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.{{APP_DOMAIN}} BASE_URL=https://workflow.royalenfield.com
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
COMPANY_WEBSITE=https://www.{{APP_DOMAIN}} COMPANY_WEBSITE=https://www.royalenfield.com
SUPPORT_EMAIL=support@{{APP_DOMAIN}} SUPPORT_EMAIL=support@royalenfield.com
``` ```
--- ---

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.{{APP_DOMAIN}}/request/REQ-2025-12-0013` - **Example:** `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
No approval/rejection buttons in emails - all actions happen within the application. 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@{{APP_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
BASE_URL=https://workflow.{{APP_DOMAIN}} BASE_URL=https://workflow.royalenfield.com
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.{{APP_DOMAIN}}/request/REQ-2025-12-0013` Example: `https://workflow.royalenfield.com/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 ✅ DONE ├── approvalConfirmation.template.ts 🔨 TODO
├── rejectionNotification.template.ts ✅ DONE ├── rejectionNotification.template.ts 🔨 TODO
├── tatReminder.template.ts ✅ DONE ├── tatReminder.template.ts 🔨 TODO
├── tatBreached.template.ts ✅ DONE ├── tatBreached.template.ts 🔨 TODO
├── workflowPaused.template.ts ✅ DONE ├── workflowPaused.template.ts 🔨 TODO
├── workflowResumed.template.ts ✅ DONE ├── workflowResumed.template.ts 🔨 TODO
├── participantAdded.template.ts ✅ DONE ├── participantAdded.template.ts 🔨 TODO
├── approverSkipped.template.ts ✅ DONE ├── approverSkipped.template.ts 🔨 TODO
└── requestClosed.template.ts ✅ DONE └── requestClosed.template.ts 🔨 TODO
``` ```
--- ---
@ -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.{{APP_DOMAIN}}/request/REQ-2025-12-0013', viewDetailsLink: 'https://workflow.royalenfield.com/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@{{APP_DOMAIN}}> EMAIL_FROM=Royal Enfield Workflow <notifications@royalenfield.com>
# Application Settings # Application Settings
BASE_URL=https://workflow.{{APP_DOMAIN}} BASE_URL=https://workflow.royalenfield.com
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
``` ```

View File

@ -7,20 +7,18 @@
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.${appDomain}`, website: 'https://www.royalenfield.com',
supportEmail: `support@${appDomain}`, supportEmail: 'support@royalenfield.com',
// Logo configuration for email headers // Logo configuration for email headers
logo: { logo: {
url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`, url: 'https://www.royalenfield.com/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp',
alt: 'Royal Enfield Logo', 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

@ -49,7 +49,7 @@ export async function shouldSendEmail(
try { try {
// Step 1: Check admin-level configuration (System Config) // Step 1: Check admin-level configuration (System Config)
const adminEmailEnabled = await isAdminEmailEnabled(emailType); const adminEmailEnabled = await isAdminEmailEnabled(emailType);
if (!adminEmailEnabled) { if (!adminEmailEnabled) {
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`); logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
return false; return false;
@ -57,7 +57,7 @@ export async function shouldSendEmail(
// Step 2: Check user-level preferences // Step 2: Check user-level preferences
const userEmailEnabled = await isUserEmailEnabled(userId, emailType); const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
if (!userEmailEnabled) { if (!userEmailEnabled) {
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`); logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
return false; return false;
@ -82,28 +82,28 @@ async function isAdminEmailEnabled(emailType: EmailNotificationType): Promise<bo
try { try {
// Step 1: Check database configuration (admin panel setting) // Step 1: Check database configuration (admin panel setting)
const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', ''); const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', '');
if (dbConfigValue) { if (dbConfigValue) {
// Parse database value (it's stored as string 'true' or 'false') // Parse database value (it's stored as string 'true' or 'false')
const dbEnabled = dbConfigValue.toLowerCase() === 'true'; const dbEnabled = dbConfigValue.toLowerCase() === 'true';
if (!dbEnabled) { if (!dbEnabled) {
logger.info('[Email] Admin has disabled email notifications globally (from database config)'); logger.info('[Email] Admin has disabled email notifications globally (from database config)');
return false; return false;
} }
logger.debug('[Email] Email notifications enabled (from database config)'); logger.debug('[Email] Email notifications enabled (from database config)');
return true; return true;
} }
// Step 2: Fall back to environment variable if database config not found // Step 2: Fall back to environment variable if database config not found
const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL; const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
if (!envEnabled) { if (!envEnabled) {
logger.info('[Email] Admin has disabled email notifications globally (from environment variable)'); logger.info('[Email] Admin has disabled email notifications globally (from environment variable)');
return false; return false;
} }
logger.debug('[Email] Email notifications enabled (from environment variable)'); logger.debug('[Email] Email notifications enabled (from environment variable)');
return true; return true;
} catch (error) { } catch (error) {
@ -131,7 +131,7 @@ async function isUserEmailEnabled(userId: string, emailType: EmailNotificationTy
// Check user's global email notification setting // Check user's global email notification setting
const enabled = (user as any).emailNotificationsEnabled !== false; const enabled = (user as any).emailNotificationsEnabled !== false;
if (!enabled) { if (!enabled) {
logger.info(`[Email] User ${userId} has disabled email notifications globally`); logger.info(`[Email] User ${userId} has disabled email notifications globally`);
} }
@ -154,7 +154,7 @@ export async function shouldSendInAppNotification(
try { try {
// Check admin config first (if SystemConfig model exists) // Check admin config first (if SystemConfig model exists)
const adminEnabled = await isAdminInAppEnabled(notificationType); const adminEnabled = await isAdminInAppEnabled(notificationType);
if (!adminEnabled) { if (!adminEnabled) {
return false; return false;
} }
@ -171,7 +171,7 @@ export async function shouldSendInAppNotification(
// Check user's global in-app notification setting // Check user's global in-app notification setting
const enabled = (user as any).inAppNotificationsEnabled !== false; const enabled = (user as any).inAppNotificationsEnabled !== false;
if (!enabled) { if (!enabled) {
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`); logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
} }
@ -191,20 +191,20 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
try { try {
// Step 1: Check database configuration (admin panel setting) // Step 1: Check database configuration (admin panel setting)
const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', ''); const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', '');
if (dbConfigValue) { if (dbConfigValue) {
// Parse database value (it's stored as string 'true' or 'false') // Parse database value (it's stored as string 'true' or 'false')
const dbEnabled = dbConfigValue.toLowerCase() === 'true'; const dbEnabled = dbConfigValue.toLowerCase() === 'true';
if (!dbEnabled) { if (!dbEnabled) {
logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)'); logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)');
return false; return false;
} }
logger.debug('[Notification] In-app notifications enabled (from database config)'); logger.debug('[Notification] In-app notifications enabled (from database config)');
return true; return true;
} }
// Step 2: Fall back to environment variable if database config not found // Step 2: Fall back to environment variable if database config not found
const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS; const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS;
if (envValue !== undefined) { if (envValue !== undefined) {
@ -216,15 +216,15 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
logger.debug('[Notification] In-app notifications enabled (from environment variable)'); logger.debug('[Notification] In-app notifications enabled (from environment variable)');
return true; return true;
} }
// Step 3: Final fallback to system config (defaults to true) // Step 3: Final fallback to system config (defaults to true)
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP; const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
if (!adminInAppEnabled) { if (!adminInAppEnabled) {
logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)'); logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)');
return false; return false;
} }
logger.debug('[Notification] In-app notifications enabled (from system config default)'); logger.debug('[Notification] In-app notifications enabled (from system config default)');
return true; return true;
} catch (error) { } catch (error) {
@ -270,7 +270,7 @@ export async function shouldSendEmailWithOverride(
userId: string, userId: string,
emailType: EmailNotificationType emailType: EmailNotificationType
): Promise<boolean> { ): Promise<boolean> {
// emails always sent (override user preference) // Critical 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

@ -6,7 +6,7 @@
*/ */
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { import {
getRequestCreatedEmail, getRequestCreatedEmail,
getApprovalRequestEmail, getApprovalRequestEmail,
getMultiApproverRequestEmail, getMultiApproverRequestEmail,
@ -21,10 +21,10 @@ import {
async function sendTestEmail() { async function sendTestEmail() {
try { try {
console.log('🚀 Creating nodemailer test account...'); console.log('🚀 Creating nodemailer test account...');
// Create a test account automatically (free, no signup needed) // Create a test account automatically (free, no signup needed)
const testAccount = await nodemailer.createTestAccount(); const testAccount = await nodemailer.createTestAccount();
console.log('✅ Test account created:', testAccount.user); console.log('✅ Test account created:', testAccount.user);
// Create transporter with test SMTP details // Create transporter with test SMTP details
@ -105,7 +105,7 @@ async function sendTestEmail() {
subject: '[REQ-2025-12-0013] Request Created Successfully', subject: '[REQ-2025-12-0013] Request Created Successfully',
html: html1 html: html1
}); });
console.log('✅ Test 1: Request Created Email'); console.log('✅ Test 1: Request Created Email');
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info1)); console.log(' Preview URL:', nodemailer.getTestMessageUrl(info1));
console.log(''); console.log('');
@ -118,7 +118,7 @@ async function sendTestEmail() {
subject: '[REQ-2025-12-0013] Approval Request - Action Required', subject: '[REQ-2025-12-0013] Approval Request - Action Required',
html: html2 html: html2
}); });
console.log('✅ Test 2: Approval Request Email'); console.log('✅ Test 2: Approval Request Email');
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info2)); console.log(' Preview URL:', nodemailer.getTestMessageUrl(info2));
console.log(''); console.log('');
@ -131,7 +131,7 @@ async function sendTestEmail() {
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn', subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
html: html3 html: html3
}); });
console.log('✅ Test 3: Multi-Approver Request Email'); console.log('✅ Test 3: Multi-Approver Request Email');
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info3)); console.log(' Preview URL:', nodemailer.getTestMessageUrl(info3));
console.log(''); console.log('');

View File

@ -8,8 +8,6 @@
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
*/ */
@ -20,7 +18,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@${appDomain}`, email: 'john.doe@royalenfield.com',
displayName: 'John Doe', displayName: 'John Doe',
department: 'Engineering', department: 'Engineering',
designation: 'Senior Engineer' designation: 'Senior Engineer'
@ -28,7 +26,7 @@ async function testRealScenario() {
const user12 = { const user12 = {
userId: 'user-12-uuid', userId: 'user-12-uuid',
email: `jane.smith@${appDomain}`, email: 'jane.smith@royalenfield.com',
displayName: 'Jane Smith', displayName: 'Jane Smith',
department: 'Management', department: 'Management',
designation: 'Engineering Manager', designation: 'Engineering Manager',
@ -54,7 +52,7 @@ async function testRealScenario() {
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head. 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@${appDomain}">john.doe@${appDomain}</a></p> <p>For questions, contact: <a href="mailto:john.doe@royalenfield.com">john.doe@royalenfield.com</a></p>
`, `,
requestType: 'Purchase', requestType: 'Purchase',
priority: 'HIGH', priority: 'HIGH',
@ -70,21 +68,21 @@ async function testRealScenario() {
{ {
levelNumber: 1, levelNumber: 1,
approverName: 'Jane Smith', approverName: 'Jane Smith',
approverEmail: `jane.smith@${appDomain}`, approverEmail: 'jane.smith@royalenfield.com',
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
}, },
{ {
levelNumber: 2, levelNumber: 2,
approverName: 'Michael Brown', approverName: 'Michael Brown',
approverEmail: `michael.brown@${appDomain}`, approverEmail: 'michael.brown@royalenfield.com',
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
}, },
{ {
levelNumber: 3, levelNumber: 3,
approverName: 'Sarah Johnson', approverName: 'Sarah Johnson',
approverEmail: `sarah.johnson@${appDomain}`, approverEmail: 'sarah.johnson@royalenfield.com',
status: 'PENDING', status: 'PENDING',
approvedAt: null approvedAt: null
} }
@ -94,7 +92,7 @@ async function testRealScenario() {
console.log('─'.repeat(80)); console.log('─'.repeat(80));
console.log('📧 Test 1: Request Created Email (to Initiator - User 10)'); console.log('📧 Test 1: Request Created Email (to Initiator - User 10)');
console.log('─'.repeat(80)); console.log('─'.repeat(80));
await emailNotificationService.sendRequestCreated( await emailNotificationService.sendRequestCreated(
requestData, requestData,
user10, user10,
@ -170,7 +168,7 @@ async function testRealScenario() {
approvedUser12, approvedUser12,
user10, user10,
false, // not final approval false, // not final approval
{ displayName: 'Michael Brown', email: `michael.brown@${appDomain}` } { displayName: 'Michael Brown', email: 'michael.brown@royalenfield.com' }
); );
console.log('\n'); console.log('\n');
@ -254,3 +252,4 @@ testRealScenario()
console.error('❌ Test failed:', error); console.error('❌ Test failed:', error);
process.exit(1); process.exit(1);
}); });

View File

@ -3,9 +3,6 @@ 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;
@ -26,29 +23,6 @@ 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;
@ -61,10 +35,10 @@ export const authenticateToken = async (
// Verify JWT token // Verify JWT token
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload; const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
// Fetch user from database to ensure they still exist and are active // Fetch user from database to ensure they still exist and are active
const user = await User.findByPk(decoded.userId); const user = await User.findByPk(decoded.userId);
if (!user || !user.isActive) { if (!user || !user.isActive) {
ResponseHandler.unauthorized(res, 'User not found or inactive'); ResponseHandler.unauthorized(res, 'User not found or inactive');
return; return;
@ -115,7 +89,7 @@ export const optionalAuth = async (
if (token) { if (token) {
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload; const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
const user = await User.findByPk(decoded.userId); const user = await User.findByPk(decoded.userId);
if (user && user.isActive) { if (user && user.isActive) {
req.user = { req.user = {
userId: user.userId, userId: user.userId,
@ -125,7 +99,7 @@ export const optionalAuth = async (
}; };
} }
} }
next(); next();
} catch (error) { } catch (error) {
// For optional auth, we don't throw errors, just continue without user // For optional auth, we don't throw errors, just continue without user

View File

@ -4,7 +4,7 @@ import cors from 'cors';
const getAllowedOrigins = (): string[] | boolean => { const getAllowedOrigins = (): string[] | boolean => {
const frontendUrl = process.env.FRONTEND_URL; const frontendUrl = process.env.FRONTEND_URL;
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
// In development, if FRONTEND_URL is not set, default to localhost:3000 // In development, if FRONTEND_URL is not set, default to localhost:3000
if (!frontendUrl) { if (!frontendUrl) {
if (isProduction) { if (isProduction) {
@ -15,13 +15,13 @@ 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 {
// Dev fallback: allow localhost:3000 // Development fallback: allow localhost:3000
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.'); console.warn('⚠️ 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'];
} }
} }
// If FRONTEND_URL is set to '*', allow all origins // If FRONTEND_URL is set to '*', allow all origins
if (frontendUrl === '*') { if (frontendUrl === '*') {
if (isProduction) { if (isProduction) {
@ -29,15 +29,15 @@ const getAllowedOrigins = (): string[] | boolean => {
} }
return true; // This allows any origin return true; // This allows any origin
} }
// Parse comma-separated URLs or use single URL // Parse comma-separated URLs or use single URL
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean); const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
if (origins.length === 0) { if (origins.length === 0) {
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!'); console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
} }
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`); console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
return origins; return origins;
}; };

View File

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

View File

@ -180,58 +180,6 @@ 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
// ============================================================================ // ============================================================================
@ -323,10 +271,10 @@ export async function metricsHandler(_req: Request, res: Response): Promise<void
*/ */
export function createMetricsRouter(): Router { export function createMetricsRouter(): Router {
const router = Router(); const router = Router();
// Metrics endpoint (GET /metrics) // Metrics endpoint (GET /metrics)
router.get('/metrics', metricsHandler); router.get('/metrics', metricsHandler);
return router; return router;
} }

View File

@ -1,12 +1,8 @@
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), // 15 minutes windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000', 10), max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 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.',
@ -14,117 +10,4 @@ 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

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

View File

@ -1,119 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Helper function to check if a column exists in a table
*/
async function columnExists(
queryInterface: QueryInterface,
tableName: string,
columnName: string
): Promise<boolean> {
try {
const tableDescription = await queryInterface.describeTable(tableName);
return columnName in tableDescription;
} catch (error) {
return false;
}
}
export async function up(queryInterface: QueryInterface): Promise<void> {
// 1. ActivityType
const activityCols = {
hsn_code: { type: DataTypes.STRING(20), allowNull: true },
sac_code: { type: DataTypes.STRING(20), allowNull: true },
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
gl_code: { type: DataTypes.STRING(20), allowNull: true },
credit_nature: { type: DataTypes.STRING(50), allowNull: true }
};
for (const [col, spec] of Object.entries(activityCols)) {
if (!(await columnExists(queryInterface, 'activity_types', col))) {
await queryInterface.addColumn('activity_types', col, spec);
}
}
// 2. GST Fields mapping for Multiple Tables
const gstFields = {
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
gst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
cgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
cgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
sgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
sgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
igst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
igst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
utgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
utgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
cess_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
cess_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
total_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
};
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses', 'claim_credit_notes'];
for (const table of tables) {
for (const [col, spec] of Object.entries(gstFields)) {
if (!(await columnExists(queryInterface, table, col))) {
await queryInterface.addColumn(table, col, spec);
}
}
}
// Add missing expense_date to DealerCompletionExpense
if (!(await columnExists(queryInterface, 'dealer_completion_expenses', 'expense_date'))) {
await queryInterface.addColumn('dealer_completion_expenses', 'expense_date', { type: DataTypes.DATEONLY, allowNull: true });
}
// 3. ClaimInvoice
const invoiceCols = {
irn: { type: DataTypes.STRING(500), allowNull: true },
ack_no: { type: DataTypes.STRING(255), allowNull: true },
ack_date: { type: DataTypes.DATE, allowNull: true },
signed_invoice: { type: DataTypes.TEXT, allowNull: true },
signed_invoice_url: { type: DataTypes.STRING(500), allowNull: true },
dealer_claim_number: { type: DataTypes.STRING(100), allowNull: true },
qr_code: { type: DataTypes.TEXT, allowNull: true },
qr_image: { type: DataTypes.TEXT, allowNull: true },
dealer_claim_date: { type: DataTypes.DATEONLY, allowNull: true },
billing_no: { type: DataTypes.STRING(100), allowNull: true },
billing_date: { type: DataTypes.DATEONLY, allowNull: true },
taxable_value: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
cgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
sgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
igst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
utgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
cess_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
tcs_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
round_off_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
place_of_supply: { type: DataTypes.STRING(255), allowNull: true },
total_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
tax_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
credit_nature: { type: DataTypes.STRING(100), allowNull: true },
consignor_gsin: { type: DataTypes.STRING(255), allowNull: true },
gstin_date: { type: DataTypes.DATEONLY, allowNull: true }
};
for (const [col, spec] of Object.entries(invoiceCols)) {
if (!(await columnExists(queryInterface, 'claim_invoices', col))) {
await queryInterface.addColumn('claim_invoices', col, spec);
}
}
// Ensure file_path exists as 'file_path'
try {
if (!(await columnExists(queryInterface, 'claim_invoices', 'file_path'))) {
if (await columnExists(queryInterface, 'claim_invoices', 'invoice_file_path')) {
await queryInterface.renameColumn('claim_invoices', 'invoice_file_path', 'file_path');
} else {
await queryInterface.addColumn('claim_invoices', 'file_path', { type: DataTypes.STRING(500), allowNull: true });
}
}
} catch (e) {
// Silently continue
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Note: Best effort rollback (usually not recommended to drop columns in shared dev unless necessary)
await queryInterface.removeColumn('dealer_completion_expenses', 'expense_date').catch(() => { });
}

View File

@ -1,17 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.addColumn('claim_invoices', 'pwc_response', {
type: DataTypes.JSON,
allowNull: true,
});
await queryInterface.addColumn('claim_invoices', 'irp_response', {
type: DataTypes.JSON,
allowNull: true,
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('claim_invoices', 'pwc_response');
await queryInterface.removeColumn('claim_invoices', 'irp_response');
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,57 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// 1. Add total_proposed_taxable_amount to dealer_claim_details
const dealerClaimDetailsTable = await queryInterface.describeTable('dealer_claim_details');
if (!dealerClaimDetailsTable.total_proposed_taxable_amount) {
await queryInterface.addColumn('dealer_claim_details', 'total_proposed_taxable_amount', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Total taxable amount from proposal or actuals if higher'
});
}
// 2. Add taxable_closed_expenses to claim_budget_tracking
const claimBudgetTrackingTable = await queryInterface.describeTable('claim_budget_tracking');
if (!claimBudgetTrackingTable.taxable_closed_expenses) {
await queryInterface.addColumn('claim_budget_tracking', 'taxable_closed_expenses', {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: 'Total taxable amount from the completion expenses'
});
}
// 3. Remove unique constraint from internal_orders to support multiple IOs
try {
// Check if the unique index exists before trying to remove it
const indexes = await queryInterface.showIndex('internal_orders');
const uniqueIndex = (indexes as any[]).find((idx: any) => idx.name === 'idx_internal_orders_request_id_unique' || (idx.fields && idx.fields[0] && idx.fields[0].attribute === 'request_id' && idx.unique));
if (uniqueIndex) {
await queryInterface.removeIndex('internal_orders', uniqueIndex.name);
// Add a non-unique index for performance
await queryInterface.addIndex('internal_orders', ['request_id'], {
name: 'idx_internal_orders_request_id'
});
}
} catch (error) {
console.error('Error removing unique index from internal_orders:', error);
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Rollback logic
try {
await queryInterface.removeColumn('dealer_claim_details', 'total_proposed_taxable_amount');
await queryInterface.removeColumn('claim_budget_tracking', 'taxable_closed_expenses');
await queryInterface.removeIndex('internal_orders', 'idx_internal_orders_request_id');
await queryInterface.addIndex('internal_orders', ['request_id'], {
name: 'idx_internal_orders_request_id_unique',
unique: true
});
} catch (error) {
console.error('Error in migration rollback:', error);
}
}

View File

@ -9,18 +9,13 @@ 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;
updatedAt: Date; updatedAt: Date;
} }
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> { } interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> {}
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes { class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
public activityTypeId!: string; public activityTypeId!: string;
@ -29,11 +24,6 @@ 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;
@ -81,31 +71,6 @@ 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,

View File

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

View File

@ -29,7 +29,6 @@ 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;
@ -51,7 +50,7 @@ interface ClaimBudgetTrackingAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'taxableClosedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> { } interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes { class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
public budgetId!: string; public budgetId!: string;
@ -65,7 +64,6 @@ 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;
@ -161,11 +159,6 @@ 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,20 +9,7 @@ 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;
@ -35,7 +22,7 @@ interface ClaimCreditNoteAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { } interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes { class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string; public creditNoteId!: string;
@ -43,20 +30,7 @@ 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;
@ -112,73 +86,8 @@ 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: 'gst_rate' field: 'credit_amount',
},
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,38 +6,11 @@ interface ClaimInvoiceAttributes {
invoiceId: string; invoiceId: string;
requestId: string; requestId: string;
invoiceNumber?: string; invoiceNumber?: string;
dmsNumber?: string;
invoiceDate?: Date; invoiceDate?: Date;
amount: number; amount?: number;
status: string; dmsNumber?: string;
irn?: string | null; invoiceFilePath?: string;
ackNo?: string | null; status?: string;
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;
@ -45,44 +18,17 @@ interface ClaimInvoiceAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'dmsNumber' | 'invoiceDate' | 'irn' | 'ackNo' | 'ackDate' | 'signedInvoice' | 'signedInvoiceUrl' | 'dealerClaimNumber' | 'dealerClaimDate' | 'billingNo' | 'billingDate' | 'taxableValue' | 'cgstTotal' | 'sgstTotal' | 'igstTotal' | 'utgstTotal' | 'cessTotal' | 'tcsAmt' | 'roundOffAmt' | 'placeOfSupply' | 'totalValueInWords' | 'taxValueInWords' | 'creditNature' | 'consignorGsin' | 'gstinDate' | 'filePath' | 'qrCode' | 'qrImage' | 'pwcResponse' | 'irpResponse' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> { } interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {}
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes { 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 dmsNumber?: string;
public invoiceDate?: Date; public invoiceDate?: Date;
public amount!: number; public amount?: number;
public status!: string; public dmsNumber?: string;
public irn?: string | null; public invoiceFilePath?: string;
public ackNo?: string | null; public status?: string;
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;
@ -115,11 +61,6 @@ 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,
@ -127,153 +68,23 @@ ClaimInvoice.init(
}, },
amount: { amount: {
type: DataTypes.DECIMAL(15, 2), type: DataTypes.DECIMAL(15, 2),
allowNull: false, allowNull: true,
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: 'ack_no' field: 'generation_status',
},
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

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

View File

@ -11,17 +11,16 @@ interface DealerClaimDetailsAttributes {
dealerName: string; dealerName: string;
dealerEmail?: string; dealerEmail?: string;
dealerPhone?: string; dealerPhone?: string;
dealerAddress?: string | null; dealerAddress?: string;
activityDate?: Date | null; activityDate?: Date;
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' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { } interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {}
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes { class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
public claimId!: string; public claimId!: string;
@ -32,12 +31,11 @@ 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 | null; public dealerAddress?: string;
public activityDate?: Date | null; public activityDate?: Date;
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;
@ -95,8 +93,8 @@ DealerClaimDetails.init(
}, },
dealerAddress: { dealerAddress: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
field: 'dealer_address', allowNull: true,
comment: 'Dealer address' field: 'dealer_address'
}, },
activityDate: { activityDate: {
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
@ -117,12 +115,6 @@ 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,28 +9,11 @@ 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;
} }
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> { } interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes { class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
public expenseId!: string; public expenseId!: string;
@ -38,23 +21,6 @@ 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;
} }
@ -97,93 +63,6 @@ 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,28 +9,12 @@ 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;
} }
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> { } interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes { class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
public costItemId!: string; public costItemId!: string;
@ -38,22 +22,6 @@ 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;
@ -98,88 +66,6 @@ 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

@ -26,7 +26,7 @@ interface InternalOrderAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> { } interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes { class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
public ioId!: string; public ioId!: string;
@ -137,7 +137,7 @@ InternalOrder.init(
indexes: [ indexes: [
{ {
fields: ['request_id'], fields: ['request_id'],
unique: false unique: true
}, },
{ {
fields: ['io_number'] fields: ['io_number']

View File

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

View File

@ -26,9 +26,6 @@ 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 = () => {
@ -182,10 +179,7 @@ export {
ClaimBudgetTracking, ClaimBudgetTracking,
Dealer, Dealer,
ActivityType, ActivityType,
DealerClaimHistory, DealerClaimHistory
ClaimInvoice,
ClaimInvoiceItem,
ClaimCreditNote
}; };
// Export default sequelize instance // Export default sequelize instance

View File

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

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