Compare commits

...

25 Commits

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1 @@
import{a as s}from"./index-BgkDE8Pi.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DyksGUTu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-VENY18zj.js.map
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};

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,31 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-BgkDE8Pi.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.js">
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-hYhqmPqT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css">
</head>
<body>
<div id="root"></div>
</body>
<link rel="modulepreload" crossorigin href="/assets/router-vendor-Bmv9jJki.js">
<link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

4
build/robots.txt Normal file
View File

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

9
build/sitemap.xml Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
- **Approver Type**: System (Auto-processed)
- **Action Type**: **AUTO** (System automatically creates activity)
- **TAT**: 1 hour
- **Mapping**: System user (`system@royalenfield.com`)
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
- **Status**: Auto-approved when triggered
### Step 5: Dealer Completion Documents
@ -55,7 +55,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
- **Approver Type**: System (Auto-processed via DMS)
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
- **TAT**: 1 hour
- **Mapping**: System user (`system@royalenfield.com`)
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
- **Status**: Auto-approved when triggered
### Step 8: Credit Note Confirmation
@ -121,7 +121,7 @@ const dealerUser = await User.findOne({ where: { email: dealerEmail } });
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
2. Find user with designation containing "Finance" or "Accountant"
3. Use configured finance team email from admin_configurations table
4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
4. Fallback: Use default finance email (e.g., finance@{{APP_DOMAIN}})
```
## 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) |
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
| `replacement` | String(50) | No | Replacement status |
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
```csv
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.royalenfield.com,,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
```
**What gets auto-generated:**

View File

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

View File

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

View File

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

View File

@ -157,7 +157,7 @@ npm run seed:config
```bash
# Edit the script
nano scripts/assign-admin-user.sql
# Change: YOUR_EMAIL@royalenfield.com
# Change: YOUR_EMAIL@{{APP_DOMAIN}}
# Run it
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
@ -170,7 +170,7 @@ psql -d royal_enfield_workflow
UPDATE users
SET role = 'ADMIN'
WHERE email = 'your-email@royalenfield.com';
WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Verify
SELECT email, role FROM users WHERE role = 'ADMIN';
@ -188,7 +188,7 @@ psql -d royal_enfield_workflow -c "\dt"
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
# Check your user
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@royalenfield.com';"
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';"
```
---
@ -241,13 +241,13 @@ Expected output:
```sql
-- Single user
UPDATE users SET role = 'MANAGEMENT'
WHERE email = 'manager@royalenfield.com';
WHERE email = 'manager@{{APP_DOMAIN}}';
-- Multiple users
UPDATE users SET role = 'MANAGEMENT'
WHERE email IN (
'manager1@royalenfield.com',
'manager2@royalenfield.com'
'manager1@{{APP_DOMAIN}}',
'manager2@{{APP_DOMAIN}}'
);
-- By department
@ -260,13 +260,13 @@ WHERE department = 'Management' AND is_active = true;
```sql
-- Single user
UPDATE users SET role = 'ADMIN'
WHERE email = 'admin@royalenfield.com';
WHERE email = 'admin@{{APP_DOMAIN}}';
-- Multiple admins
UPDATE users SET role = 'ADMIN'
WHERE email IN (
'admin1@royalenfield.com',
'admin2@royalenfield.com'
'admin1@{{APP_DOMAIN}}',
'admin2@{{APP_DOMAIN}}'
);
-- By department
@ -331,7 +331,7 @@ SELECT
mobile_phone,
array_length(ad_groups, 1) as ad_group_count
FROM users
WHERE email = 'your-email@royalenfield.com';
WHERE email = 'your-email@{{APP_DOMAIN}}';
```
---
@ -344,7 +344,7 @@ WHERE email = 'your-email@royalenfield.com';
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
-H "Content-Type: application/json" \
-d '{
"email": "test@royalenfield.com",
"email": "test@{{APP_DOMAIN}}",
"displayName": "Test User",
"oktaSub": "test-sub-123"
}'
@ -353,14 +353,14 @@ curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
### 2. Check User Created with Default Role
```sql
SELECT email, role FROM users WHERE email = 'test@royalenfield.com';
SELECT email, role FROM users WHERE email = 'test@{{APP_DOMAIN}}';
-- Expected: role = 'USER'
```
### 3. Update to ADMIN
```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
```
### 4. Verify API Access
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
# Login and get token
curl -X POST http://localhost:5000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@royalenfield.com", ...}'
-d '{"email": "test@{{APP_DOMAIN}}", ...}'
# Try admin endpoint (should work if ADMIN role)
curl http://localhost:5000/api/v1/admin/configurations \
@ -449,7 +449,7 @@ npm run migrate
```sql
-- Check if user exists
SELECT * FROM users WHERE email = 'your-email@royalenfield.com';
SELECT * FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Check Okta sub
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
@ -459,7 +459,7 @@ SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
```sql
-- Verify role
SELECT email, role, is_active FROM users WHERE email = 'your-email@royalenfield.com';
SELECT email, role, is_active FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
-- Check 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 |
| **Environment** | Production |
| **Domain** | `https://reflow.royalenfield.com` |
| **Domain** | `https://reflow.{{APP_DOMAIN}}` |
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
| **Storage Type** | Google Cloud Storage (GCS) |
| **Region** | `asia-south1` (Mumbai) |
@ -325,8 +325,8 @@ Create `cors-config-prod.json`:
[
{
"origin": [
"https://reflow.royalenfield.com",
"https://www.royalenfield.com"
"https://reflow.{{APP_DOMAIN}}",
"https://www.{{APP_DOMAIN}}"
],
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
"responseHeader": [

View File

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

View File

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

View File

@ -450,16 +450,16 @@ Before Migration:
+-------------------------+-----------+
| email | is_admin |
+-------------------------+-----------+
| admin@royalenfield.com | true |
| user1@royalenfield.com | false |
| admin@{{APP_DOMAIN}} | true |
| user1@{{APP_DOMAIN}} | false |
+-------------------------+-----------+
After Migration:
+-------------------------+-----------+-----------+
| email | role | is_admin |
+-------------------------+-----------+-----------+
| admin@royalenfield.com | ADMIN | true |
| user1@royalenfield.com | USER | false |
| admin@{{APP_DOMAIN}} | ADMIN | true |
| user1@{{APP_DOMAIN}} | USER | false |
+-------------------------+-----------+-----------+
```
@ -473,17 +473,17 @@ After Migration:
-- Make user a MANAGEMENT role
UPDATE users
SET role = 'MANAGEMENT', is_admin = false
WHERE email = 'manager@royalenfield.com';
WHERE email = 'manager@{{APP_DOMAIN}}';
-- Make user an ADMIN role
UPDATE users
SET role = 'ADMIN', is_admin = true
WHERE email = 'admin@royalenfield.com';
WHERE email = 'admin@{{APP_DOMAIN}}';
-- Revert to USER role
UPDATE users
SET role = 'USER', is_admin = false
WHERE email = 'user@royalenfield.com';
WHERE email = 'user@{{APP_DOMAIN}}';
```
### 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
UPDATE users
SET role = 'ADMIN', is_admin = true
WHERE email IN ('admin@royalenfield.com', 'it.admin@royalenfield.com');
WHERE email IN ('admin@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}');
-- Make specific users MANAGEMENT
UPDATE users
SET role = 'MANAGEMENT', is_admin = false
WHERE email IN ('manager@royalenfield.com', 'auditor@royalenfield.com');
WHERE email IN ('manager@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}');
-- Verify roles
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
@ -219,7 +219,7 @@ GROUP BY role;
-- Check specific user
SELECT email, role, is_admin
FROM users
WHERE email = 'your-email@royalenfield.com';
WHERE email = 'your-email@{{APP_DOMAIN}}';
```
### Test 2: Test API Access
@ -356,7 +356,7 @@ WHERE designation ILIKE '%manager%' OR designation ILIKE '%head%';
```sql
SELECT email, role, is_admin
FROM users
WHERE email = 'your-email@royalenfield.com';
WHERE email = 'your-email@{{APP_DOMAIN}}';
```
---

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import type { AuthenticatedRequest } from '../types/express';
import { DealerClaimService } from '../services/dealerClaim.service';
import { ResponseHandler } from '../utils/responseHandler';
import { translateEInvoiceError } from '../utils/einvoiceErrors';
import logger from '../utils/logger';
import { gcsStorageService } from '../services/gcsStorage.service';
import { Document } from '../models/Document';
@ -11,6 +12,11 @@ import { sapIntegrationService } from '../services/sapIntegration.service';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { DealerClaimDetails } from '../models/DealerClaimDetails';
import { ClaimInvoice } from '../models/ClaimInvoice';
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
import { ActivityType } from '../models/ActivityType';
export class DealerClaimController {
private dealerClaimService = new DealerClaimService();
@ -75,7 +81,7 @@ export class DealerClaimController {
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
return ResponseHandler.error(res, error.message, 400);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error creating claim request:', error);
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
@ -301,7 +307,7 @@ export class DealerClaimController {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
@ -360,7 +366,7 @@ export class DealerClaimController {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
@ -420,7 +426,7 @@ export class DealerClaimController {
try {
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: file.originalname,
@ -480,7 +486,7 @@ export class DealerClaimController {
try {
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const uploadResult = await gcsStorageService.uploadFileWithFallback({
buffer: fileBuffer,
originalName: attendanceSheetFile.originalname,
@ -561,18 +567,18 @@ export class DealerClaimController {
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { ioNumber } = req.query;
if (!ioNumber || typeof ioNumber !== 'string') {
return ResponseHandler.error(res, 'IO number is required', 400);
}
// Fetch IO details from SAP (will return mock data until SAP is integrated)
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
if (!ioValidation.isValid) {
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
}
return ResponseHandler.success(res, {
ioNumber: ioValidation.ioNumber,
availableBalance: ioValidation.availableBalance,
@ -623,7 +629,7 @@ export class DealerClaimController {
}
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
// Log received data for debugging
logger.info('[DealerClaimController] updateIODetails received:', {
requestId,
@ -633,7 +639,7 @@ export class DealerClaimController {
receivedBlockedAmount: blockedAmount, // Original value from request
userId,
});
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
if (blockAmount > 0) {
if (availableBalance === undefined) {
@ -649,9 +655,9 @@ export class DealerClaimController {
blockedAmount: blockAmount,
// remainingBalance will be calculated by the service from SAP's response
};
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
await this.dealerClaimService.updateIODetails(
requestId,
ioData,
@ -660,7 +666,7 @@ export class DealerClaimController {
// Fetch and return the updated IO details from database
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
if (updatedIO) {
return ResponseHandler.success(res, {
message: 'IO blocked successfully in SAP',
@ -751,7 +757,66 @@ export class DealerClaimController {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error updating e-invoice:', error);
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
// Translate technical PWC/IRP error codes to user-friendly messages
const userFacingMessage = translateEInvoiceError(errorMessage);
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
}
}
/**
* 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);
}
}
@ -875,7 +940,7 @@ export class DealerClaimController {
// First validate IO number
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
if (!ioValidation.isValid) {
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
}
@ -923,5 +988,98 @@ export class DealerClaimController {
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
}
}
}
/**
* Download Invoice CSV
* GET /api/v1/dealer-claims/:requestId/e-invoice/csv
*/
async downloadInvoiceCsv(req: Request, res: Response): Promise<void> {
try {
const identifier = req.params.requestId;
// Use helper to find workflow
const workflow = await this.findWorkflowByIdentifier(identifier);
if (!workflow) {
return ResponseHandler.error(res, 'Workflow request not found', 404);
}
const requestId = (workflow as any).requestId || (workflow as any).request_id;
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
// Fetch related data
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
ClaimInvoice.findOne({ where: { requestId } }),
ClaimInvoiceItem.findAll({ where: { requestId }, order: [['slNo', 'ASC']] }),
DealerClaimDetails.findOne({ where: { requestId } }),
InternalOrder.findOne({ where: { requestId } })
]);
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
let sapRefNo = '';
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
sapRefNo = activity?.sapRefNo || '';
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
// Construct CSV
const headers = [
'TRNS_UNIQ_NO',
'CLAIM_NUMBER',
'INV_NUMBER',
'DEALER_CODE',
'IO_NUMBER',
'CLAIM_DOC_TYP',
'CLAIM_DATE',
'CLAIM_AMT',
'GST_AMT',
'GST_PERCENTAG'
];
const rows = items.map(item => {
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
const claimNumber = requestNumber;
const invNumber = invoice?.invoiceNumber || '';
const dealerCode = claimDetails?.dealerCode || '';
const ioNumber = internalOrder?.ioNumber || '';
const claimDocTyp = sapRefNo;
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
const claimAmt = item.assAmt;
// Zero out tax for Non-GST
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
return [
trnsUniqNo,
claimNumber,
invNumber,
dealerCode,
ioNumber,
claimDocTyp,
claimDate,
claimAmt,
totalTax.toFixed(2),
gstPercentag
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
res.status(200).send(csvContent);
return;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerClaimController] Error downloading invoice CSV:', error);
return ResponseHandler.error(res, 'Failed to download invoice CSV', 500, errorMessage);
}
}
}

View File

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

View File

@ -10,13 +10,37 @@ export class UserController {
this.userService = new UserService();
}
async getAllUsers(req: Request, res: Response): Promise<void> {
try {
const users = await this.userService.getAllUsers();
const result = {
users: users.map(u => ({
userId: u.userId,
email: u.email,
displayName: u.displayName,
department: u.department,
designation: u.designation,
isActive: u.isActive,
})),
total: users.length
};
ResponseHandler.success(res, result, 'All users fetched');
} catch (error) {
logger.error('Failed to fetch all users', { error });
ResponseHandler.error(res, 'Failed to fetch all users', 500);
}
}
async searchUsers(req: Request, res: Response): Promise<void> {
try {
const q = String(req.query.q || '').trim();
const limit = Number(req.query.limit || 10);
const source = String(req.query.source || 'default') as 'local' | 'okta' | 'default';
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
const users = await this.userService.searchUsers(q, limit, currentUserId);
const users = await this.userService.searchUsers(q, limit, currentUserId, source);
const result = users.map(u => ({
userId: (u as any).userId,
@ -69,6 +93,31 @@ export class UserController {
}
}
async getUserById(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params;
const user = await this.userService.getUserById(userId);
if (!user) {
ResponseHandler.error(res, 'User not found', 404);
return;
}
ResponseHandler.success(res, {
userId: user.userId,
email: user.email,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
department: user.department,
isActive: user.isActive
}, 'User fetched');
} catch (error) {
logger.error('Failed to fetch user by ID', { error });
ResponseHandler.error(res, 'Failed to fetch user by ID', 500);
}
}
/**
* Ensure user exists in database (create if not exists)
* Called when user is selected/tagged in the frontend

View File

@ -12,7 +12,7 @@ import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { getRequestMetadata } from '@utils/requestUtils';
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service';
import { DealerClaimService } from '@services/dealerClaim.service';
import logger from '@utils/logger';
@ -27,6 +27,15 @@ export class WorkflowController {
// Validate initiator exists
await validateInitiator(req.user.userId);
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
const dealerCode = req.body.dealerCode || (req.body as any).dealer_code;
if (dealerCode || validatedData.templateType === 'DEALER CLAIM') {
if (!dealerCode) {
throw new Error('Dealer code is required for dealer claim requests');
}
await validateDealerUser(dealerCode);
}
// Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
let approvalLevels = validatedData.approvalLevels || [];
if (!approvalLevels.length && (req.body as any).approvers) {
@ -170,6 +179,15 @@ export class WorkflowController {
// Validate initiator exists
await validateInitiator(userId);
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
const dealerCode = parsed.dealerCode || parsed.dealer_code;
if (dealerCode || validated.templateType === 'DEALER CLAIM') {
if (!dealerCode) {
throw new Error('Dealer code is required for dealer claim requests');
}
await validateDealerUser(dealerCode);
}
// Use the approval levels from validation (already transformed above)
let approvalLevels = validated.approvalLevels || [];

View File

@ -172,7 +172,7 @@ This document outlines all email templates required for the Dealer Claim Managem
- Initiator (for record)
- Finance team
- **Template**: `creditNoteSent.template.ts` (NEW)
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
- **Status**: Required implementation
- **Notification Type**: `credit_note_sent`
- **Data Needed**:
- Credit note number
@ -184,7 +184,7 @@ This document outlines all email templates required for the Dealer Claim Managem
- Reason for credit note
- Download link (if available)
- **Notes**:
- Currently has TODO comment for email implementation
- Planned for email implementation
- Critical for dealer notification
---
@ -246,7 +246,7 @@ This document outlines all email templates required for the Dealer Claim Managem
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
- Priority: High
- When: Credit note is sent to dealer (Step 8)
- Currently has TODO comment
- Planned for implementation
---
@ -255,7 +255,7 @@ This document outlines all email templates required for the Dealer Claim Managem
### High Priority (Critical for Workflow)
1. **Activity Created** - Currently using generic notification, should be branded
2. **E-Invoice Generated** - Important for financial tracking
3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
3. **Credit Note Sent** - Critical for dealer notification
### Medium Priority (Nice to Have)
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_PORT=587
SMTP_SECURE=false
SMTP_USER=notifications@royalenfield.com
SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your-app-specific-password
EMAIL_FROM=RE Flow <noreply@royalenfield.com>
EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
# Email Settings
EMAIL_ENABLED=true
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
EMAIL_RETRY_ATTEMPTS=3
# Application
BASE_URL=https://workflow.royalenfield.com
BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield
COMPANY_WEBSITE=https://www.royalenfield.com
SUPPORT_EMAIL=support@royalenfield.com
COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
SUPPORT_EMAIL=support@{{APP_DOMAIN}}
```
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,13 +9,18 @@ interface ActivityTypeAttributes {
taxationType?: string;
sapRefNo?: string;
isActive: boolean;
hsnCode?: string | null;
sacCode?: string | null;
gstRate?: number | null;
glCode?: string | null;
creditNature?: 'Commercial' | 'GST' | null;
createdBy: string;
updatedBy?: string;
createdAt: Date;
updatedAt: Date;
}
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> {}
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> { }
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
public activityTypeId!: string;
@ -24,6 +29,11 @@ class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAtt
public taxationType?: string;
public sapRefNo?: string;
public isActive!: boolean;
public hsnCode?: string | null;
public sacCode?: string | null;
public gstRate?: number | null;
public glCode?: string | null;
public creditNature?: 'Commercial' | 'GST' | null;
public createdBy!: string;
public updatedBy?: string;
public createdAt!: Date;
@ -71,6 +81,31 @@ ActivityType.init(
defaultValue: true,
field: 'is_active'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
sacCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'sac_code'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'gst_rate'
},
glCode: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'gl_code'
},
creditNature: {
type: DataTypes.ENUM('Commercial', 'GST'),
allowNull: true,
field: 'credit_nature'
},
createdBy: {
type: DataTypes.UUID,
allowNull: false,

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

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

View File

@ -29,6 +29,7 @@ interface ClaimBudgetTrackingAttributes {
ioBlockedAt?: Date;
// Closed Expenses
closedExpenses?: number;
taxableClosedExpenses?: number;
closedExpensesSubmittedAt?: Date;
// Final Claim Amount
finalClaimAmount?: number;
@ -50,7 +51,7 @@ interface ClaimBudgetTrackingAttributes {
updatedAt: Date;
}
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'taxableClosedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> { }
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
public budgetId!: string;
@ -64,6 +65,7 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
public ioBlockedAmount?: number;
public ioBlockedAt?: Date;
public closedExpenses?: number;
public taxableClosedExpenses?: number;
public closedExpensesSubmittedAt?: Date;
public finalClaimAmount?: number;
public finalClaimAmountApprovedAt?: Date;
@ -159,6 +161,11 @@ ClaimBudgetTracking.init(
allowNull: true,
field: 'closed_expenses_submitted_at'
},
taxableClosedExpenses: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'taxable_closed_expenses'
},
finalClaimAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,

View File

@ -9,7 +9,20 @@ interface ClaimCreditNoteAttributes {
invoiceId?: string;
creditNoteNumber?: string;
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;
creditNoteFilePath?: string;
status?: string;
@ -22,7 +35,7 @@ interface ClaimCreditNoteAttributes {
updatedAt: Date;
}
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
public creditNoteId!: string;
@ -30,7 +43,20 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
public invoiceId?: string;
public creditNoteNumber?: string;
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 creditNoteFilePath?: string;
public status?: string;
@ -86,8 +112,73 @@ ClaimCreditNote.init(
},
creditNoteAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
field: 'credit_amount'
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'credit_amount',
field: 'gst_rate'
},
gstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'gst_amt'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cgst_rate'
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cgst_amt'
},
sgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'sgst_rate'
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'sgst_amt'
},
igstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'igst_rate'
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'igst_amt'
},
utgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'utgst_rate'
},
utgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'utgst_amt'
},
cessRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cess_rate'
},
cessAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cess_amt'
},
totalAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_amt'
},
sapDocumentNumber: {
type: DataTypes.STRING(100),

View File

@ -6,11 +6,38 @@ interface ClaimInvoiceAttributes {
invoiceId: string;
requestId: string;
invoiceNumber?: string;
invoiceDate?: Date;
amount?: number;
dmsNumber?: string;
invoiceFilePath?: string;
status?: string;
invoiceDate?: Date;
amount: number;
status: string;
irn?: string | null;
ackNo?: string | null;
ackDate?: Date | null;
signedInvoice?: string | null;
signedInvoiceUrl?: string | null;
dealerClaimNumber?: string | null;
dealerClaimDate?: Date | null;
billingNo?: string | null;
billingDate?: Date | null;
taxableValue?: number | null;
cgstTotal?: number | null;
sgstTotal?: number | null;
igstTotal?: number | null;
utgstTotal?: number | null;
cessTotal?: number | null;
tcsAmt?: number | null;
roundOffAmt?: number | null;
placeOfSupply?: string | null;
totalValueInWords?: string | null;
taxValueInWords?: string | null;
creditNature?: string | null;
consignorGsin?: string | null;
gstinDate?: Date | null;
filePath?: string | null;
qrCode?: string | null;
qrImage?: string | null;
pwcResponse?: any;
irpResponse?: any;
errorMessage?: string;
generatedAt?: Date;
description?: string;
@ -18,17 +45,44 @@ interface ClaimInvoiceAttributes {
updatedAt: Date;
}
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {}
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'dmsNumber' | 'invoiceDate' | 'irn' | 'ackNo' | 'ackDate' | 'signedInvoice' | 'signedInvoiceUrl' | 'dealerClaimNumber' | 'dealerClaimDate' | 'billingNo' | 'billingDate' | 'taxableValue' | 'cgstTotal' | 'sgstTotal' | 'igstTotal' | 'utgstTotal' | 'cessTotal' | 'tcsAmt' | 'roundOffAmt' | 'placeOfSupply' | 'totalValueInWords' | 'taxValueInWords' | 'creditNature' | 'consignorGsin' | 'gstinDate' | 'filePath' | 'qrCode' | 'qrImage' | 'pwcResponse' | 'irpResponse' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> { }
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
public invoiceId!: string;
public requestId!: string;
public invoiceNumber?: string;
public invoiceDate?: Date;
public amount?: number;
public dmsNumber?: string;
public invoiceFilePath?: string;
public status?: string;
public invoiceDate?: Date;
public amount!: number;
public status!: string;
public irn?: string | null;
public ackNo?: string | null;
public ackDate?: Date | null;
public signedInvoice?: string | null;
public signedInvoiceUrl?: string | null;
public dealerClaimNumber?: string | null;
public dealerClaimDate?: Date | null;
public billingNo?: string | null;
public billingDate?: Date | null;
public taxableValue?: number | null;
public cgstTotal?: number | null;
public sgstTotal?: number | null;
public igstTotal?: number | null;
public utgstTotal?: number | null;
public cessTotal?: number | null;
public tcsAmt?: number | null;
public roundOffAmt?: number | null;
public placeOfSupply?: string | null;
public totalValueInWords?: string | null;
public taxValueInWords?: string | null;
public creditNature?: string | null;
public consignorGsin?: string | null;
public gstinDate?: Date | null;
public filePath?: string | null;
public qrCode?: string | null;
public qrImage?: string | null;
public pwcResponse?: any;
public irpResponse?: any;
public errorMessage?: string;
public generatedAt?: Date;
public description?: string;
@ -61,6 +115,11 @@ ClaimInvoice.init(
allowNull: true,
field: 'invoice_number',
},
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number',
},
invoiceDate: {
type: DataTypes.DATEONLY,
allowNull: true,
@ -68,23 +127,153 @@ ClaimInvoice.init(
},
amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
allowNull: false,
field: 'invoice_amount',
},
dmsNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dms_number',
},
invoiceFilePath: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'invoice_file_path',
},
status: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'PENDING',
field: 'generation_status'
},
irn: {
type: DataTypes.STRING(500),
allowNull: true
},
ackNo: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'generation_status',
field: 'ack_no'
},
ackDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'ack_date'
},
signedInvoice: {
type: DataTypes.TEXT,
allowNull: true,
field: 'signed_invoice'
},
signedInvoiceUrl: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'signed_invoice_url'
},
dealerClaimNumber: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'dealer_claim_number'
},
dealerClaimDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'dealer_claim_date'
},
billingNo: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'billing_no'
},
billingDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'billing_date'
},
taxableValue: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'taxable_value'
},
cgstTotal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cgst_total'
},
sgstTotal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'sgst_total'
},
igstTotal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'igst_total'
},
utgstTotal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'utgst_total'
},
cessTotal: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cess_total'
},
tcsAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'tcs_amt'
},
roundOffAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'round_off_amt'
},
placeOfSupply: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'place_of_supply'
},
totalValueInWords: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'total_value_in_words'
},
taxValueInWords: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'tax_value_in_words'
},
creditNature: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'credit_nature'
},
consignorGsin: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'consignor_gsin'
},
gstinDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'gstin_date'
},
filePath: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'file_path'
},
qrCode: {
type: DataTypes.TEXT,
allowNull: true,
field: 'qr_code'
},
qrImage: {
type: DataTypes.TEXT,
allowNull: true,
field: 'qr_image'
},
pwcResponse: {
type: DataTypes.JSON,
allowNull: true,
field: 'pwc_response'
},
irpResponse: {
type: DataTypes.JSON,
allowNull: true,
field: 'irp_response'
},
errorMessage: {
type: DataTypes.TEXT,

View File

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

View File

@ -11,16 +11,17 @@ interface DealerClaimDetailsAttributes {
dealerName: string;
dealerEmail?: string;
dealerPhone?: string;
dealerAddress?: string;
activityDate?: Date;
dealerAddress?: string | null;
activityDate?: Date | null;
location?: string;
periodStartDate?: Date;
periodEndDate?: Date;
totalProposedTaxableAmount?: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {}
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { }
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
public claimId!: string;
@ -31,11 +32,12 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
public dealerName!: string;
public dealerEmail?: string;
public dealerPhone?: string;
public dealerAddress?: string;
public activityDate?: Date;
public dealerAddress?: string | null;
public activityDate?: Date | null;
public location?: string;
public periodStartDate?: Date;
public periodEndDate?: Date;
public totalProposedTaxableAmount?: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -93,8 +95,8 @@ DealerClaimDetails.init(
},
dealerAddress: {
type: DataTypes.TEXT,
allowNull: true,
field: 'dealer_address'
field: 'dealer_address',
comment: 'Dealer address'
},
activityDate: {
type: DataTypes.DATEONLY,
@ -115,6 +117,12 @@ DealerClaimDetails.init(
allowNull: true,
field: 'period_end_date'
},
totalProposedTaxableAmount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_proposed_taxable_amount',
comment: 'Total taxable amount from proposal or actuals if higher'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -9,11 +9,28 @@ interface DealerCompletionExpenseAttributes {
completionId?: string | null;
description: string;
amount: number;
gstRate?: number;
gstAmt?: number;
quantity?: number;
hsnCode?: string;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
sgstAmt?: number;
igstRate?: number;
igstAmt?: number;
utgstRate?: number;
utgstAmt?: number;
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
expenseDate: Date;
createdAt: Date;
updatedAt: Date;
}
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> { }
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
public expenseId!: string;
@ -21,6 +38,23 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
public completionId?: string | null;
public description!: string;
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
public sgstAmt?: number;
public igstRate?: number;
public igstAmt?: number;
public utgstRate?: number;
public utgstAmt?: number;
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public expenseDate!: Date;
public createdAt!: Date;
public updatedAt!: Date;
}
@ -63,6 +97,93 @@ DealerCompletionExpense.init(
allowNull: false,
field: 'amount',
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'gst_rate'
},
gstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cgst_rate'
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cgst_amt'
},
sgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'sgst_rate'
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'sgst_amt'
},
igstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'igst_rate'
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'igst_amt'
},
utgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'utgst_rate'
},
utgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'utgst_amt'
},
cessRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cess_rate'
},
cessAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cess_amt'
},
totalAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
expenseDate: {
type: DataTypes.DATE,
allowNull: false,
field: 'expense_date',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -9,12 +9,28 @@ interface DealerProposalCostItemAttributes {
requestId: string;
itemDescription: string;
amount: number;
gstRate?: number;
gstAmt?: number;
quantity?: number;
hsnCode?: string;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
sgstAmt?: number;
igstRate?: number;
igstAmt?: number;
utgstRate?: number;
utgstAmt?: number;
cessRate?: number;
cessAmt?: number;
totalAmt?: number;
isService?: boolean;
itemOrder: number;
createdAt: Date;
updatedAt: Date;
}
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> { }
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
public costItemId!: string;
@ -22,6 +38,22 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
public requestId!: string;
public itemDescription!: string;
public amount!: number;
public gstRate?: number;
public gstAmt?: number;
public quantity?: number;
public hsnCode?: string;
public cgstRate?: number;
public cgstAmt?: number;
public sgstRate?: number;
public sgstAmt?: number;
public igstRate?: number;
public igstAmt?: number;
public utgstRate?: number;
public utgstAmt?: number;
public cessRate?: number;
public cessAmt?: number;
public totalAmt?: number;
public isService?: boolean;
public itemOrder!: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -66,6 +98,88 @@ DealerProposalCostItem.init(
type: DataTypes.DECIMAL(15, 2),
allowNull: false
},
gstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'gst_rate'
},
gstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'gst_amt'
},
quantity: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 1,
field: 'quantity'
},
hsnCode: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'hsn_code'
},
cgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cgst_rate'
},
cgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cgst_amt'
},
sgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'sgst_rate'
},
sgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'sgst_amt'
},
igstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'igst_rate'
},
igstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'igst_amt'
},
utgstRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'utgst_rate'
},
utgstAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'utgst_amt'
},
cessRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'cess_rate'
},
cessAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'cess_amt'
},
totalAmt: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
field: 'total_amt'
},
isService: {
type: DataTypes.BOOLEAN,
allowNull: true,
defaultValue: false,
field: 'is_service'
},
itemOrder: {
type: DataTypes.INTEGER,
allowNull: false,

View File

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

View File

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

View File

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

View File

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

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