Compare commits
No commits in common. "8753c9477dda3c558b4678bb2089775a0df843b1" and "798688e4c2523270dce6518e73da9db32cc67b15" have entirely different histories.
8753c9477d
...
798688e4c2
@ -1326,9 +1326,9 @@ GCP_KEY_FILE=./config/gcp-key.json
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
SMTP_USER=notifications@royalenfield.com
|
||||||
SMTP_PASSWORD=your_smtp_password
|
SMTP_PASSWORD=your_smtp_password
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||||
|
|
||||||
# AI Service (for conclusion generation)
|
# AI Service (for conclusion generation)
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
|
|||||||
@ -155,13 +155,13 @@ export async function calculateBusinessDays(
|
|||||||
2. ✅ Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils`
|
2. ✅ Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils`
|
||||||
3. ✅ Replaced lines 64-65 with proper working hours calculation (now lines 66-77)
|
3. ✅ Replaced lines 64-65 with proper working hours calculation (now lines 66-77)
|
||||||
4. ✅ Gets priority from workflow
|
4. ✅ Gets priority from workflow
|
||||||
5. Done: Test TAT breach alerts
|
5. ⏳ **TODO:** Test TAT breach alerts
|
||||||
|
|
||||||
### Step 2: Add Business Days Function ✅ **DONE**
|
### Step 2: Add Business Days Function ✅ **DONE**
|
||||||
1. ✅ Opened `Re_Backend/src/utils/tatTimeUtils.ts`
|
1. ✅ Opened `Re_Backend/src/utils/tatTimeUtils.ts`
|
||||||
2. ✅ Added `calculateBusinessDays()` function (lines 697-758)
|
2. ✅ Added `calculateBusinessDays()` function (lines 697-758)
|
||||||
3. ✅ Exported the function
|
3. ✅ Exported the function
|
||||||
4. Done: Test with various date ranges
|
4. ⏳ **TODO:** Test with various date ranges
|
||||||
|
|
||||||
### Step 3: Update Workflow Aging Report ✅ **DONE**
|
### Step 3: Update Workflow Aging Report ✅ **DONE**
|
||||||
1. ✅ Built report endpoint using `calculateBusinessDays()`
|
1. ✅ Built report endpoint using `calculateBusinessDays()`
|
||||||
|
|||||||
@ -19,10 +19,10 @@ This command will output something like:
|
|||||||
```
|
```
|
||||||
=======================================
|
=======================================
|
||||||
Public Key:
|
Public Key:
|
||||||
{{VAPID_PUBLIC_KEY}}
|
BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||||
|
|
||||||
Private Key:
|
Private Key:
|
||||||
{{VAPID_PRIVATE_KEY}}
|
aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||||
|
|
||||||
=======================================
|
=======================================
|
||||||
```
|
```
|
||||||
@ -59,9 +59,9 @@ Add the generated keys to your backend `.env` file:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
# Notification Service Worker credentials (Web Push / VAPID)
|
# Notification Service Worker credentials (Web Push / VAPID)
|
||||||
VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
|
VAPID_PUBLIC_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||||
VAPID_PRIVATE_KEY={{VAPID_PRIVATE_KEY}}
|
VAPID_PRIVATE_KEY=aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890AbCdEfGhIjKlMnOpQrStUvWxYz
|
||||||
VAPID_CONTACT=mailto:{{ADMIN_EMAIL}}
|
VAPID_CONTACT=mailto:admin@royalenfield.com
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
@ -75,7 +75,7 @@ Add the **SAME** `VAPID_PUBLIC_KEY` to your frontend `.env` file:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
# Push Notifications (Web Push / VAPID)
|
# Push Notifications (Web Push / VAPID)
|
||||||
VITE_PUBLIC_VAPID_KEY={{VAPID_PUBLIC_KEY}}
|
VITE_PUBLIC_VAPID_KEY=BEl62iUYgUivxIkvpY5kXK3t3b9i5X8YzA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:**
|
**Important:**
|
||||||
|
|||||||
@ -98,7 +98,7 @@ npm run dev
|
|||||||
1. Server will start automatically
|
1. Server will start automatically
|
||||||
2. Log in via SSO
|
2. Log in via SSO
|
||||||
3. Run this SQL to make yourself admin:
|
3. Run this SQL to make yourself admin:
|
||||||
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{APP_DOMAIN}}';
|
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@royalenfield.com';
|
||||||
|
|
||||||
[Config Seed] ✅ Default configurations seeded successfully (30 settings)
|
[Config Seed] ✅ Default configurations seeded successfully (30 settings)
|
||||||
info: ✅ Server started successfully on port 5000
|
info: ✅ Server started successfully on port 5000
|
||||||
@ -112,7 +112,7 @@ psql -d royal_enfield_workflow
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
WHERE email = 'your-email@royalenfield.com';
|
||||||
|
|
||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|||||||
@ -471,7 +471,7 @@ The backend supports web push notifications via VAPID (Voluntary Application Ser
|
|||||||
```
|
```
|
||||||
VAPID_PUBLIC_KEY=<your-public-key>
|
VAPID_PUBLIC_KEY=<your-public-key>
|
||||||
VAPID_PRIVATE_KEY=<your-private-key>
|
VAPID_PRIVATE_KEY=<your-private-key>
|
||||||
VAPID_CONTACT=mailto:admin@{{APP_DOMAIN}}
|
VAPID_CONTACT=mailto:admin@royalenfield.com
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Add to Frontend `.env`:**
|
3. **Add to Frontend `.env`:**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1
build/assets/charts-vendor-BVfwAPj-.js.map
Normal file
1
build/assets/charts-vendor-BVfwAPj-.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -1 +1,2 @@
|
|||||||
import{a as s}from"./index-hYhqmPqT.js";import"./radix-vendor-GwO0o3Qg.js";import"./charts-vendor-waDbLeao.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-3qilyUHW.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-Bmv9jJki.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
import{a as s}from"./index-BgkDE8Pi.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DyksGUTu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||||
|
//# sourceMappingURL=conclusionApi-VENY18zj.js.map
|
||||||
1
build/assets/conclusionApi-VENY18zj.js.map
Normal file
1
build/assets/conclusionApi-VENY18zj.js.map
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"conclusionApi-VENY18zj.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}
|
||||||
67
build/assets/index-BgkDE8Pi.js
Normal file
67
build/assets/index-BgkDE8Pi.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-BgkDE8Pi.js.map
Normal file
1
build/assets/index-BgkDE8Pi.js.map
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-D5NCgjQR.css
Normal file
1
build/assets/index-D5NCgjQR.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/radix-vendor-CYvDqP9X.js.map
Normal file
1
build/assets/radix-vendor-CYvDqP9X.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/router-vendor-B_rK4TXr.js.map
Normal file
1
build/assets/router-vendor-B_rK4TXr.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
build/assets/ui-vendor-DyksGUTu.js
Normal file
3
build/assets/ui-vendor-DyksGUTu.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/ui-vendor-DyksGUTu.js.map
Normal file
1
build/assets/ui-vendor-DyksGUTu.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -1,31 +1,31 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
<meta name="theme-color" content="#2d4a3e" />
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<!-- Preload essential fonts and icons -->
|
<!-- Preload critical fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<script type="module" crossorigin src="/assets/index-hYhqmPqT.js"></script>
|
<script type="module" crossorigin src="/assets/index-BgkDE8Pi.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js">
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DyksGUTu.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-Bmv9jJki.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D5NCgjQR.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow: /api/
|
|
||||||
|
|
||||||
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://reflow.royalenfield.com</loc>
|
|
||||||
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
|
|
||||||
<changefreq>daily</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
@ -1,8 +1,39 @@
|
|||||||
# docker-compose.full.yml
|
# =============================================================================
|
||||||
# Synced with streamlined infrastructure
|
# RE Workflow - Full Stack Docker Compose
|
||||||
|
# Includes: Application + Database + Monitoring Stack
|
||||||
|
# =============================================================================
|
||||||
|
# Usage:
|
||||||
|
# docker-compose -f docker-compose.full.yml up -d
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ===========================================================================
|
||||||
|
# APPLICATION SERVICES
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: re_workflow_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-laxman}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: re_workflow_redis
|
container_name: re_workflow_redis
|
||||||
@ -19,24 +50,70 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
clamav:
|
backend:
|
||||||
image: clamav/clamav:latest
|
build:
|
||||||
container_name: re_clamav
|
context: .
|
||||||
ports:
|
dockerfile: Dockerfile
|
||||||
- "3310:3310"
|
container_name: re_workflow_backend
|
||||||
volumes:
|
|
||||||
- clamav_data:/var/lib/clamav
|
|
||||||
environment:
|
environment:
|
||||||
- CLAMAV_NO_FRESHCLAMD=false
|
NODE_ENV: development
|
||||||
healthcheck:
|
DB_HOST: postgres
|
||||||
test: ["CMD", "clamdcheck"]
|
DB_PORT: 5432
|
||||||
interval: 60s
|
DB_USER: ${DB_USER:-laxman}
|
||||||
timeout: 10s
|
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
retries: 5
|
DB_NAME: ${DB_NAME:-re_workflow_db}
|
||||||
start_period: 120s
|
REDIS_URL: redis://redis:6379
|
||||||
restart: unless-stopped
|
PORT: 5000
|
||||||
|
# Loki for logging
|
||||||
|
LOKI_HOST: http://loki:3100
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- re_workflow_network
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# MONITORING SERVICES
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.47.2
|
||||||
|
container_name: re_prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./monitoring/prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=15d'
|
||||||
|
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||||
|
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
networks:
|
||||||
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:2.9.2
|
image: grafana/loki:2.9.2
|
||||||
@ -79,12 +156,15 @@ services:
|
|||||||
- GF_SECURITY_ADMIN_USER=admin
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
|
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_FEATURE_TOGGLES_ENABLE=publicDashboards
|
||||||
|
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource,grafana-piechart-panel
|
||||||
volumes:
|
volumes:
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
||||||
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- prometheus
|
||||||
- loki
|
- loki
|
||||||
networks:
|
networks:
|
||||||
- re_workflow_network
|
- re_workflow_network
|
||||||
@ -95,13 +175,54 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
volumes:
|
node-exporter:
|
||||||
redis_data:
|
image: prom/node-exporter:v1.6.1
|
||||||
clamav_data:
|
container_name: re_node_exporter
|
||||||
loki_data:
|
ports:
|
||||||
promtail_data:
|
- "9100:9100"
|
||||||
grafana_data:
|
networks:
|
||||||
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.26.0
|
||||||
|
container_name: re_alertmanager
|
||||||
|
ports:
|
||||||
|
- "9093:9093"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||||
|
- alertmanager_data:/alertmanager
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||||
|
- '--storage.path=/alertmanager'
|
||||||
|
networks:
|
||||||
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# NETWORKS
|
||||||
|
# ===========================================================================
|
||||||
networks:
|
networks:
|
||||||
re_workflow_network:
|
re_workflow_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
name: re_workflow_network
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# VOLUMES
|
||||||
|
# ===========================================================================
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: re_postgres_data
|
||||||
|
redis_data:
|
||||||
|
name: re_redis_data
|
||||||
|
prometheus_data:
|
||||||
|
name: re_prometheus_data
|
||||||
|
loki_data:
|
||||||
|
name: re_loki_data
|
||||||
|
promtail_data:
|
||||||
|
name: re_promtail_data
|
||||||
|
grafana_data:
|
||||||
|
name: re_grafana_data
|
||||||
|
alertmanager_data:
|
||||||
|
name: re_alertmanager_data
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,28 @@
|
|||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
# Streamlined infrastructure for local development
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: re_workflow_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-laxman}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-re_workflow_db}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- re_workflow_network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-laxman}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: re_workflow_redis
|
container_name: re_workflow_redis
|
||||||
@ -19,88 +39,43 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
clamav:
|
backend:
|
||||||
image: clamav/clamav:latest
|
build:
|
||||||
container_name: re_clamav
|
context: .
|
||||||
ports:
|
dockerfile: Dockerfile
|
||||||
- "3310:3310"
|
container_name: re_workflow_backend
|
||||||
volumes:
|
|
||||||
- clamav_data:/var/lib/clamav
|
|
||||||
environment:
|
environment:
|
||||||
- CLAMAV_NO_FRESHCLAMD=false
|
NODE_ENV: development
|
||||||
healthcheck:
|
DB_HOST: postgres
|
||||||
test: ["CMD", "clamdcheck"]
|
DB_PORT: 5432
|
||||||
interval: 60s
|
DB_USER: ${DB_USER:-laxman}
|
||||||
timeout: 10s
|
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
retries: 5
|
DB_NAME: ${DB_NAME:-re_workflow_db}
|
||||||
start_period: 120s
|
REDIS_URL: redis://redis:6379
|
||||||
restart: unless-stopped
|
PORT: 5000
|
||||||
networks:
|
|
||||||
- re_workflow_network
|
|
||||||
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:2.9.2
|
|
||||||
container_name: re_loki
|
|
||||||
ports:
|
ports:
|
||||||
- "3100:3100"
|
- "5000:5000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
- ./logs:/app/logs
|
||||||
- loki_data:/loki
|
- ./uploads:/app/uploads
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
|
||||||
networks:
|
networks:
|
||||||
- re_workflow_network
|
- re_workflow_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:5000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})\""]
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:2.9.2
|
|
||||||
container_name: re_promtail
|
|
||||||
volumes:
|
|
||||||
- ./monitoring/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
|
|
||||||
- ./logs:/var/log/app:ro
|
|
||||||
- promtail_data:/tmp/promtail
|
|
||||||
command: -config.file=/etc/promtail/config.yml
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
networks:
|
|
||||||
- re_workflow_network
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:10.2.2
|
|
||||||
container_name: re_grafana
|
|
||||||
ports:
|
|
||||||
- "3001:3000"
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=REWorkflow@2024
|
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
|
||||||
- ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
|
||||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
networks:
|
|
||||||
- re_workflow_network
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
clamav_data:
|
|
||||||
loki_data:
|
|
||||||
promtail_data:
|
|
||||||
grafana_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
re_workflow_network:
|
re_workflow_network:
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
# Dealer Claim Financial Settlement Workflow
|
|
||||||
|
|
||||||
This document outlines the workflow for financial settlement of dealer claims within the Royal Enfield platform, following the transition from direct DMS integration to an Azure File Storage (AFS) based data exchange with SAP.
|
|
||||||
|
|
||||||
## Workflow Overview
|
|
||||||
|
|
||||||
The financial settlement process ensures that dealer claims are legally documented and financially settled through Royal Enfield's SAP system.
|
|
||||||
|
|
||||||
### 1. Legal Compliance: PWC E-Invoicing
|
|
||||||
Once the **Dealer Completion Documents** are submitted and approved by the **Initiator (Requestor Evaluation)**, the system triggers the legal compliance step.
|
|
||||||
|
|
||||||
- **Service**: `PWCIntegrationService`
|
|
||||||
- **Action**: Generates a signed E-Invoice via PWC API.
|
|
||||||
- **Output**: IRN (Invoice Reference Number), Ack No, Ack Date, Signed Invoice (PDF/B64), and QR Code.
|
|
||||||
- **Purpose**: Ensures the claim is legally recognized under GST regulations.
|
|
||||||
|
|
||||||
### 2. Financial Posting: AFS/CSV Integration
|
|
||||||
The financial settlement is handled by exchanging data files with SAP via **Azure File Storage (AFS)**.
|
|
||||||
|
|
||||||
- **Action**: The system generates a **CSV file** containing the following details:
|
|
||||||
- Invoice Number (from PWC)
|
|
||||||
- Invoice Amount (with/without GST as per activity type)
|
|
||||||
- GL Code (Resolved based on Activity Type/IO)
|
|
||||||
- Internal Order (IO) Number
|
|
||||||
- Dealer Code
|
|
||||||
- **Storage**: CSV is uploaded to a designated folder in AFS.
|
|
||||||
- **SAP Role**: SAP periodically polls AFS, picks up the CSV, and posts the transaction internally.
|
|
||||||
|
|
||||||
### 3. Payment Outcome: Credit Note
|
|
||||||
The result of the financial posting in SAP is a **Credit Note**.
|
|
||||||
|
|
||||||
- **Workflow**:
|
|
||||||
- SAP generates a Credit Note and uploads it back to AFS.
|
|
||||||
- RE Backend monitors the AFS folder.
|
|
||||||
- Once a Credit Note is detected, the system retrieves it and attaches it to the workflow request.
|
|
||||||
- An email notification (using `creditNoteSent.template.ts`) is sent to the dealer.
|
|
||||||
|
|
||||||
## Sequence Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Dealer
|
|
||||||
participant Backend
|
|
||||||
participant PWC
|
|
||||||
participant AFS as Azure File Storage
|
|
||||||
participant SAP
|
|
||||||
|
|
||||||
Dealer->>Backend: Submit Completion Docs (Actuals)
|
|
||||||
Backend->>Backend: Initiator Approval
|
|
||||||
Backend->>PWC: Generate Signed E-Invoice
|
|
||||||
PWC-->>Backend: Return IRN & QR Code
|
|
||||||
Backend->>Backend: Generate Settlement CSV
|
|
||||||
Backend->>AFS: Upload CSV
|
|
||||||
SAP->>AFS: Pick up CSV
|
|
||||||
SAP->>SAP: Post Financials
|
|
||||||
SAP->>AFS: Upload Credit Note
|
|
||||||
Backend->>AFS: Poll/Retrieve Credit Note
|
|
||||||
Backend->>Dealer: Send Credit Note Notification
|
|
||||||
```
|
|
||||||
|
|
||||||
## GL Code Resolution
|
|
||||||
The GL Code is solved dynamically based on:
|
|
||||||
1. **Activity Type**: Each activity (e.g., Marketing Event, Demo) has a primary GL mapping.
|
|
||||||
2. **Internal Order (IO)**: If specific IO logic is required, the GL can be overridden.
|
|
||||||
|
|
||||||
## Summary of Integration Points
|
|
||||||
| Component | Integration Type | Responsibility |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **PWC** | REST API | Legal E-Invoice |
|
|
||||||
| **AFS (Azure)** | File Storage SDK | CSV Exchange |
|
|
||||||
| **SAP** | Batch Processing | Financial Posting & Credit Note |
|
|
||||||
@ -34,7 +34,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
|||||||
- **Approver Type**: System (Auto-processed)
|
- **Approver Type**: System (Auto-processed)
|
||||||
- **Action Type**: **AUTO** (System automatically creates activity)
|
- **Action Type**: **AUTO** (System automatically creates activity)
|
||||||
- **TAT**: 1 hour
|
- **TAT**: 1 hour
|
||||||
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
- **Mapping**: System user (`system@royalenfield.com`)
|
||||||
- **Status**: Auto-approved when triggered
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
### Step 5: Dealer Completion Documents
|
### Step 5: Dealer Completion Documents
|
||||||
@ -55,7 +55,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
|||||||
- **Approver Type**: System (Auto-processed via DMS)
|
- **Approver Type**: System (Auto-processed via DMS)
|
||||||
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
||||||
- **TAT**: 1 hour
|
- **TAT**: 1 hour
|
||||||
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
- **Mapping**: System user (`system@royalenfield.com`)
|
||||||
- **Status**: Auto-approved when triggered
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
### Step 8: Credit Note Confirmation
|
### Step 8: Credit Note Confirmation
|
||||||
@ -121,7 +121,7 @@ const dealerUser = await User.findOne({ where: { email: dealerEmail } });
|
|||||||
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
||||||
2. Find user with designation containing "Finance" or "Accountant"
|
2. Find user with designation containing "Finance" or "Accountant"
|
||||||
3. Use configured finance team email from admin_configurations table
|
3. Use configured finance team email from admin_configurations table
|
||||||
4. Fallback: Use default finance email (e.g., finance@{{APP_DOMAIN}})
|
4. Fallback: Use default finance email (e.g., finance@royalenfield.com)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|||||||
@ -112,7 +112,7 @@ Your CSV file must have these **44 columns** in the following order:
|
|||||||
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
|
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
|
||||||
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
|
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
|
||||||
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
|
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
|
||||||
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
|
| `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) |
|
||||||
| `replacement` | String(50) | No | Replacement status |
|
| `replacement` | String(50) | No | Replacement status |
|
||||||
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
|
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
|
||||||
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
|
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
|
||||||
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
|
|||||||
|
|
||||||
```csv
|
```csv
|
||||||
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
|
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
|
||||||
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.royalenfield.com,,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
||||||
```
|
```
|
||||||
|
|
||||||
**What gets auto-generated:**
|
**What gets auto-generated:**
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
# Dealer Integration Implementation Status
|
|
||||||
|
|
||||||
This document summarizes the changes made to integrate the external Royal Enfield Dealer API and implement the dealer validation logic during request creation.
|
|
||||||
|
|
||||||
## Completed Work
|
|
||||||
|
|
||||||
### 1. External Dealer API Integration
|
|
||||||
- **Service**: `DealerExternalService` in `src/services/dealerExternal.service.ts`
|
|
||||||
- Implemented `getDealerByCode` to fetch data from `https://api-uat2.royalenfield.com/DealerMaster`.
|
|
||||||
- Returns real-time GSTIN, Address, and location details.
|
|
||||||
- **Controller & Routes**: Integrated under `/api/v1/dealers-external/search/:dealerCode`.
|
|
||||||
- **Enrichment**: `DealerService.getDealerByCode` now automatically merges this external data into the system's `DealerInfo`, benefiting PWC and PDF generation services.
|
|
||||||
|
|
||||||
### 2. Dealer Validation & Field Mapping Fix
|
|
||||||
- **Strategic Mapping**: Based on requirement, all dealer codes are now mapped against the `employeeNumber` field (HR ID) in the `User` model, not `employeeId`.
|
|
||||||
- **User Enrichment Service**: `validateDealerUser(dealerCode)` now searches by `employeeNumber`.
|
|
||||||
- **SSO Alignment**: `AuthService.ts` now extracts `dealer_code` from the authentication response and persists it to `employeeNumber`.
|
|
||||||
- **Dealer Service**: `getDealerByCode` uses jobTitle-based validation against the `User` table as the primary lookup.
|
|
||||||
|
|
||||||
### 3. Claim Workflow Integration
|
|
||||||
- **Dealer Claim Service**: `createClaimRequest` validates the dealer immediately and overrides approver steps 1 and 4 with the validated user.
|
|
||||||
- **Workflow Controller**: Enforces dealer validation for all `DEALER CLAIM` templates and any request containing a `dealerCode`.
|
|
||||||
|
|
||||||
### 4. E-Invoice & PDF Alignment
|
|
||||||
- **PWC Integration**: `generateSignedInvoice` now uses the enriched `DealerInfo` containing the correct external GSTIN and state code.
|
|
||||||
- **Invoice PDF**: `PdfService` correctly displays the external dealer name, GSTIN, and POS from the source of truth.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
All integrated components have been verified via test scripts and end-to-end flow analysis. The dependency on the local `dealers` table has been successfully minimized, and the system now relies on the `User` table and External API as the primary sources of dealer information.
|
|
||||||
@ -56,7 +56,7 @@ users {
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"userId": "uuid-1",
|
"userId": "uuid-1",
|
||||||
"email": "john.doe@{{APP_DOMAIN}}",
|
"email": "john.doe@royalenfield.com",
|
||||||
"employeeId": "E12345", // Regular employee ID
|
"employeeId": "E12345", // Regular employee ID
|
||||||
"designation": "Software Engineer",
|
"designation": "Software Engineer",
|
||||||
"department": "IT",
|
"department": "IT",
|
||||||
@ -68,7 +68,7 @@ users {
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"userId": "uuid-2",
|
"userId": "uuid-2",
|
||||||
"email": "test.2@{{APP_DOMAIN}}",
|
"email": "test.2@royalenfield.com",
|
||||||
"employeeId": "RE-MH-001", // Dealer code stored here
|
"employeeId": "RE-MH-001", // Dealer code stored here
|
||||||
"designation": "Dealer",
|
"designation": "Dealer",
|
||||||
"department": "Dealer Operations",
|
"department": "Dealer Operations",
|
||||||
|
|||||||
@ -98,8 +98,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
|||||||
|
|
||||||
**Base URL Examples:**
|
**Base URL Examples:**
|
||||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
|
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
|
||||||
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice`
|
||||||
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice`
|
||||||
|
|
||||||
### 3.2 Request Headers
|
### 3.2 Request Headers
|
||||||
|
|
||||||
@ -205,8 +205,8 @@ User-Agent: DMS-Webhook-Client/1.0
|
|||||||
|
|
||||||
**Base URL Examples:**
|
**Base URL Examples:**
|
||||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
|
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
|
||||||
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
- UAT: `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note`
|
||||||
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
- Production: `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note`
|
||||||
|
|
||||||
### 4.2 Request Headers
|
### 4.2 Request Headers
|
||||||
|
|
||||||
@ -563,8 +563,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
|||||||
| Environment | Invoice Webhook URL | Credit Note Webhook URL |
|
| Environment | Invoice Webhook URL | Credit Note Webhook URL |
|
||||||
|-------------|---------------------|-------------------------|
|
|-------------|---------------------|-------------------------|
|
||||||
| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` |
|
| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` |
|
||||||
| UAT | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
| UAT | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow-uat.royalenfield.com/api/v1/webhooks/dms/credit-note` |
|
||||||
| Production | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
| Production | `https://reflow.royalenfield.com/api/v1/webhooks/dms/invoice` | `https://reflow.royalenfield.com/api/v1/webhooks/dms/credit-note` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -157,7 +157,7 @@ npm run seed:config
|
|||||||
```bash
|
```bash
|
||||||
# Edit the script
|
# Edit the script
|
||||||
nano scripts/assign-admin-user.sql
|
nano scripts/assign-admin-user.sql
|
||||||
# Change: YOUR_EMAIL@{{APP_DOMAIN}}
|
# Change: YOUR_EMAIL@royalenfield.com
|
||||||
|
|
||||||
# Run it
|
# Run it
|
||||||
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
|
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
|
||||||
@ -170,7 +170,7 @@ psql -d royal_enfield_workflow
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
WHERE email = 'your-email@royalenfield.com';
|
||||||
|
|
||||||
-- Verify
|
-- Verify
|
||||||
SELECT email, role FROM users WHERE role = 'ADMIN';
|
SELECT email, role FROM users WHERE role = 'ADMIN';
|
||||||
@ -188,7 +188,7 @@ psql -d royal_enfield_workflow -c "\dt"
|
|||||||
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
|
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
|
||||||
|
|
||||||
# Check your user
|
# Check your user
|
||||||
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';"
|
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@royalenfield.com';"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -241,13 +241,13 @@ Expected output:
|
|||||||
```sql
|
```sql
|
||||||
-- Single user
|
-- Single user
|
||||||
UPDATE users SET role = 'MANAGEMENT'
|
UPDATE users SET role = 'MANAGEMENT'
|
||||||
WHERE email = 'manager@{{APP_DOMAIN}}';
|
WHERE email = 'manager@royalenfield.com';
|
||||||
|
|
||||||
-- Multiple users
|
-- Multiple users
|
||||||
UPDATE users SET role = 'MANAGEMENT'
|
UPDATE users SET role = 'MANAGEMENT'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'manager1@{{APP_DOMAIN}}',
|
'manager1@royalenfield.com',
|
||||||
'manager2@{{APP_DOMAIN}}'
|
'manager2@royalenfield.com'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- By department
|
-- By department
|
||||||
@ -260,13 +260,13 @@ WHERE department = 'Management' AND is_active = true;
|
|||||||
```sql
|
```sql
|
||||||
-- Single user
|
-- Single user
|
||||||
UPDATE users SET role = 'ADMIN'
|
UPDATE users SET role = 'ADMIN'
|
||||||
WHERE email = 'admin@{{APP_DOMAIN}}';
|
WHERE email = 'admin@royalenfield.com';
|
||||||
|
|
||||||
-- Multiple admins
|
-- Multiple admins
|
||||||
UPDATE users SET role = 'ADMIN'
|
UPDATE users SET role = 'ADMIN'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'admin1@{{APP_DOMAIN}}',
|
'admin1@royalenfield.com',
|
||||||
'admin2@{{APP_DOMAIN}}'
|
'admin2@royalenfield.com'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- By department
|
-- By department
|
||||||
@ -331,7 +331,7 @@ SELECT
|
|||||||
mobile_phone,
|
mobile_phone,
|
||||||
array_length(ad_groups, 1) as ad_group_count
|
array_length(ad_groups, 1) as ad_group_count
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
WHERE email = 'your-email@royalenfield.com';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -344,7 +344,7 @@ WHERE email = 'your-email@{{APP_DOMAIN}}';
|
|||||||
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"email": "test@{{APP_DOMAIN}}",
|
"email": "test@royalenfield.com",
|
||||||
"displayName": "Test User",
|
"displayName": "Test User",
|
||||||
"oktaSub": "test-sub-123"
|
"oktaSub": "test-sub-123"
|
||||||
}'
|
}'
|
||||||
@ -353,14 +353,14 @@ curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
|||||||
### 2. Check User Created with Default Role
|
### 2. Check User Created with Default Role
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT email, role FROM users WHERE email = 'test@{{APP_DOMAIN}}';
|
SELECT email, role FROM users WHERE email = 'test@royalenfield.com';
|
||||||
-- Expected: role = 'USER'
|
-- Expected: role = 'USER'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Update to ADMIN
|
### 3. Update to ADMIN
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
|
UPDATE users SET role = 'ADMIN' WHERE email = 'test@royalenfield.com';
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Verify API Access
|
### 4. Verify API Access
|
||||||
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
|
|||||||
# Login and get token
|
# Login and get token
|
||||||
curl -X POST http://localhost:5000/api/v1/auth/login \
|
curl -X POST http://localhost:5000/api/v1/auth/login \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email": "test@{{APP_DOMAIN}}", ...}'
|
-d '{"email": "test@royalenfield.com", ...}'
|
||||||
|
|
||||||
# Try admin endpoint (should work if ADMIN role)
|
# Try admin endpoint (should work if ADMIN role)
|
||||||
curl http://localhost:5000/api/v1/admin/configurations \
|
curl http://localhost:5000/api/v1/admin/configurations \
|
||||||
@ -449,7 +449,7 @@ npm run migrate
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Check if user exists
|
-- Check if user exists
|
||||||
SELECT * FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
SELECT * FROM users WHERE email = 'your-email@royalenfield.com';
|
||||||
|
|
||||||
-- Check Okta sub
|
-- Check Okta sub
|
||||||
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
||||||
@ -459,7 +459,7 @@ SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Verify role
|
-- Verify role
|
||||||
SELECT email, role, is_active FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
SELECT email, role, is_active FROM users WHERE email = 'your-email@royalenfield.com';
|
||||||
|
|
||||||
-- Check role enum
|
-- Check role enum
|
||||||
\dT+ user_role_enum
|
\dT+ user_role_enum
|
||||||
|
|||||||
@ -29,7 +29,7 @@ This guide provides step-by-step instructions for setting up Google Cloud Storag
|
|||||||
|------|------------------|
|
|------|------------------|
|
||||||
| **Application** | Royal Enfield Workflow System |
|
| **Application** | Royal Enfield Workflow System |
|
||||||
| **Environment** | Production |
|
| **Environment** | Production |
|
||||||
| **Domain** | `https://reflow.{{APP_DOMAIN}}` |
|
| **Domain** | `https://reflow.royalenfield.com` |
|
||||||
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
|
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
|
||||||
| **Storage Type** | Google Cloud Storage (GCS) |
|
| **Storage Type** | Google Cloud Storage (GCS) |
|
||||||
| **Region** | `asia-south1` (Mumbai) |
|
| **Region** | `asia-south1` (Mumbai) |
|
||||||
@ -325,8 +325,8 @@ Create `cors-config-prod.json`:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"origin": [
|
"origin": [
|
||||||
"https://reflow.{{APP_DOMAIN}}",
|
"https://reflow.royalenfield.com",
|
||||||
"https://www.{{APP_DOMAIN}}"
|
"https://www.royalenfield.com"
|
||||||
],
|
],
|
||||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||||
"responseHeader": [
|
"responseHeader": [
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|------|-------|
|
|------|-------|
|
||||||
| **Application** | RE Workflow System |
|
| **Application** | RE Workflow System |
|
||||||
| **Environment** | UAT |
|
| **Environment** | UAT |
|
||||||
| **Domain** | https://reflow-uat.{{APP_DOMAIN}} |
|
| **Domain** | https://reflow-uat.royalenfield.com |
|
||||||
| **Purpose** | Store workflow documents and attachments |
|
| **Purpose** | Store workflow documents and attachments |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -131,8 +131,8 @@ Apply this CORS policy to allow browser uploads:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"origin": [
|
"origin": [
|
||||||
"https://reflow-uat.{{APP_DOMAIN}}",
|
"https://reflow-uat.royalenfield.com",
|
||||||
"https://reflow.{{APP_DOMAIN}}"
|
"https://reflow.royalenfield.com"
|
||||||
],
|
],
|
||||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||||
"responseHeader": [
|
"responseHeader": [
|
||||||
|
|||||||
@ -72,8 +72,8 @@ The Users API returns a complete user object:
|
|||||||
"employeeID": "E09994",
|
"employeeID": "E09994",
|
||||||
"title": "Supports Business Applications (SAP) portfolio",
|
"title": "Supports Business Applications (SAP) portfolio",
|
||||||
"department": "Deputy Manager - Digital & IT",
|
"department": "Deputy Manager - Digital & IT",
|
||||||
"login": "sanjaysahu@{{APP_DOMAIN}}",
|
"login": "sanjaysahu@Royalenfield.com",
|
||||||
"email": "sanjaysahu@{{APP_DOMAIN}}"
|
"email": "sanjaysahu@royalenfield.com"
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@ -127,7 +127,7 @@ Example log:
|
|||||||
### Test with curl
|
### Test with curl
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --location 'https://{{IDP_DOMAIN}}/api/v1/users/testuser10@eichergroup.com' \
|
curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \
|
||||||
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
|
--header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \
|
||||||
--header 'Accept: application/json'
|
--header 'Accept: application/json'
|
||||||
```
|
```
|
||||||
|
|||||||
@ -450,16 +450,16 @@ Before Migration:
|
|||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
| email | is_admin |
|
| email | is_admin |
|
||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
| admin@{{APP_DOMAIN}} | true |
|
| admin@royalenfield.com | true |
|
||||||
| user1@{{APP_DOMAIN}} | false |
|
| user1@royalenfield.com | false |
|
||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
|
|
||||||
After Migration:
|
After Migration:
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
| email | role | is_admin |
|
| email | role | is_admin |
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
| admin@{{APP_DOMAIN}} | ADMIN | true |
|
| admin@royalenfield.com | ADMIN | true |
|
||||||
| user1@{{APP_DOMAIN}} | USER | false |
|
| user1@royalenfield.com | USER | false |
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -473,17 +473,17 @@ After Migration:
|
|||||||
-- Make user a MANAGEMENT role
|
-- Make user a MANAGEMENT role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT', is_admin = false
|
SET role = 'MANAGEMENT', is_admin = false
|
||||||
WHERE email = 'manager@{{APP_DOMAIN}}';
|
WHERE email = 'manager@royalenfield.com';
|
||||||
|
|
||||||
-- Make user an ADMIN role
|
-- Make user an ADMIN role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN', is_admin = true
|
SET role = 'ADMIN', is_admin = true
|
||||||
WHERE email = 'admin@{{APP_DOMAIN}}';
|
WHERE email = 'admin@royalenfield.com';
|
||||||
|
|
||||||
-- Revert to USER role
|
-- Revert to USER role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'USER', is_admin = false
|
SET role = 'USER', is_admin = false
|
||||||
WHERE email = 'user@{{APP_DOMAIN}}';
|
WHERE email = 'user@royalenfield.com';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Via API (Admin Endpoint)
|
### Via API (Admin Endpoint)
|
||||||
|
|||||||
@ -47,12 +47,12 @@ psql -d royal_enfield_db -f scripts/assign-user-roles.sql
|
|||||||
-- Make specific users ADMIN
|
-- Make specific users ADMIN
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN', is_admin = true
|
SET role = 'ADMIN', is_admin = true
|
||||||
WHERE email IN ('admin@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}');
|
WHERE email IN ('admin@royalenfield.com', 'it.admin@royalenfield.com');
|
||||||
|
|
||||||
-- Make specific users MANAGEMENT
|
-- Make specific users MANAGEMENT
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT', is_admin = false
|
SET role = 'MANAGEMENT', is_admin = false
|
||||||
WHERE email IN ('manager@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}');
|
WHERE email IN ('manager@royalenfield.com', 'auditor@royalenfield.com');
|
||||||
|
|
||||||
-- Verify roles
|
-- Verify roles
|
||||||
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
|
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
|
||||||
@ -219,7 +219,7 @@ GROUP BY role;
|
|||||||
-- Check specific user
|
-- Check specific user
|
||||||
SELECT email, role, is_admin
|
SELECT email, role, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
WHERE email = 'your-email@royalenfield.com';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test 2: Test API Access
|
### Test 2: Test API Access
|
||||||
@ -356,7 +356,7 @@ WHERE designation ILIKE '%manager%' OR designation ILIKE '%head%';
|
|||||||
```sql
|
```sql
|
||||||
SELECT email, role, is_admin
|
SELECT email, role, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
WHERE email = 'your-email@royalenfield.com';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -314,7 +314,7 @@ JWT_EXPIRY=24h
|
|||||||
REFRESH_TOKEN_EXPIRY=7d
|
REFRESH_TOKEN_EXPIRY=7d
|
||||||
|
|
||||||
# Okta Configuration
|
# Okta Configuration
|
||||||
OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||||
OKTA_CLIENT_ID=your-client-id
|
OKTA_CLIENT_ID=your-client-id
|
||||||
OKTA_CLIENT_SECRET=your-client-secret
|
OKTA_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
@ -334,7 +334,7 @@ GCP_BUCKET_PUBLIC=true
|
|||||||
|
|
||||||
**Identity Provider**: Okta
|
**Identity Provider**: Okta
|
||||||
- **Domain**: Configurable via `OKTA_DOMAIN` environment variable
|
- **Domain**: Configurable via `OKTA_DOMAIN` environment variable
|
||||||
- **Default**: `https://{{IDP_DOMAIN}}`
|
- **Default**: `https://dev-830839.oktapreview.com`
|
||||||
- **Protocol**: OAuth 2.0 / OpenID Connect (OIDC)
|
- **Protocol**: OAuth 2.0 / OpenID Connect (OIDC)
|
||||||
- **Grant Types**: Authorization Code, Resource Owner Password Credentials
|
- **Grant Types**: Authorization Code, Resource Owner Password Credentials
|
||||||
|
|
||||||
@ -650,7 +650,7 @@ graph LR
|
|||||||
{
|
{
|
||||||
"userId": "uuid",
|
"userId": "uuid",
|
||||||
"employeeId": "EMP001",
|
"employeeId": "EMP001",
|
||||||
"email": "user@{{APP_DOMAIN}}",
|
"email": "user@royalenfield.com",
|
||||||
"role": "USER" | "MANAGEMENT" | "ADMIN",
|
"role": "USER" | "MANAGEMENT" | "ADMIN",
|
||||||
"iat": 1234567890,
|
"iat": 1234567890,
|
||||||
"exp": 1234654290
|
"exp": 1234654290
|
||||||
@ -1048,7 +1048,7 @@ JWT_EXPIRY=24h
|
|||||||
REFRESH_TOKEN_EXPIRY=7d
|
REFRESH_TOKEN_EXPIRY=7d
|
||||||
|
|
||||||
# Okta
|
# Okta
|
||||||
OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||||
OKTA_CLIENT_ID=your-client-id
|
OKTA_CLIENT_ID=your-client-id
|
||||||
OKTA_CLIENT_SECRET=your-client-secret
|
OKTA_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
@ -1063,7 +1063,7 @@ GCP_BUCKET_PUBLIC=true
|
|||||||
**Frontend (.env):**
|
**Frontend (.env):**
|
||||||
```env
|
```env
|
||||||
VITE_API_BASE_URL=https://api.rebridge.co.in/api/v1
|
VITE_API_BASE_URL=https://api.rebridge.co.in/api/v1
|
||||||
VITE_OKTA_DOMAIN=https://{{IDP_DOMAIN}}
|
VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||||
VITE_OKTA_CLIENT_ID=your-client-id
|
VITE_OKTA_CLIENT_ID=your-client-id
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@ await this.createClaimApprovalLevels(
|
|||||||
isAuto: false,
|
isAuto: false,
|
||||||
approverType: 'department_lead' as const,
|
approverType: 'department_lead' as const,
|
||||||
approverId: departmentLead?.userId || null,
|
approverId: departmentLead?.userId || null,
|
||||||
approverEmail: departmentLead?.email || initiator.manager || `deptlead@${appDomain}`,
|
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com',
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -181,7 +181,7 @@ POST http://localhost:5000/api/v1/auth/login
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"username": "john.doe@{{APP_DOMAIN}}",
|
"username": "john.doe@royalenfield.com",
|
||||||
"password": "SecurePassword123!"
|
"password": "SecurePassword123!"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
20
env.example
20
env.example
@ -26,8 +26,8 @@ REFRESH_TOKEN_EXPIRY=7d
|
|||||||
SESSION_SECRET=your_session_secret_here_min_32_chars
|
SESSION_SECRET=your_session_secret_here_min_32_chars
|
||||||
|
|
||||||
# Cloud Storage (GCP)
|
# Cloud Storage (GCP)
|
||||||
GCP_PROJECT_ID={{GCP_PROJECT_ID}}
|
GCP_PROJECT_ID=re-workflow-project
|
||||||
GCP_BUCKET_NAME={{GCP_BUCKET_NAME}}
|
GCP_BUCKET_NAME=re-workflow-documents
|
||||||
GCP_KEY_FILE=./config/gcp-key.json
|
GCP_KEY_FILE=./config/gcp-key.json
|
||||||
|
|
||||||
# Google Secret Manager (Optional - for production)
|
# Google Secret Manager (Optional - for production)
|
||||||
@ -41,9 +41,9 @@ USE_GOOGLE_SECRET_MANAGER=false
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
SMTP_USER=notifications@royalenfield.com
|
||||||
SMTP_PASSWORD=your_smtp_password
|
SMTP_PASSWORD=your_smtp_password
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||||
|
|
||||||
# AI Service (for conclusion generation) - Vertex AI Gemini
|
# AI Service (for conclusion generation) - Vertex AI Gemini
|
||||||
# Uses service account credentials from GCP_KEY_FILE
|
# Uses service account credentials from GCP_KEY_FILE
|
||||||
@ -55,7 +55,7 @@ VERTEX_AI_LOCATION=asia-south1
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FILE_PATH=./logs
|
LOG_FILE_PATH=./logs
|
||||||
APP_VERSION={{APP_VERSION}}
|
APP_VERSION=1.2.0
|
||||||
|
|
||||||
# ============ Loki Configuration (Grafana Log Aggregation) ============
|
# ============ Loki Configuration (Grafana Log Aggregation) ============
|
||||||
LOKI_HOST= # e.g., http://loki:3100 or http://monitoring.cloudtopiaa.com:3100
|
LOKI_HOST= # e.g., http://loki:3100 or http://monitoring.cloudtopiaa.com:3100
|
||||||
@ -66,7 +66,7 @@ LOKI_PASSWORD= # Optional: Basic auth password
|
|||||||
CORS_ORIGIN="*"
|
CORS_ORIGIN="*"
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
# File Upload
|
# File Upload
|
||||||
@ -83,16 +83,16 @@ OKTA_CLIENT_ID={{okta_client_id}}
|
|||||||
OKTA_CLIENT_SECRET={{okta_client_secret}}
|
OKTA_CLIENT_SECRET={{okta_client_secret}}
|
||||||
|
|
||||||
# Notificaton Service Worker credentials
|
# Notificaton Service Worker credentials
|
||||||
VAPID_PUBLIC_KEY={{VAPID_PUBLIC_KEY}}
|
VAPID_PUBLIC_KEY={{vapid_public_key}} note: same key need to add on front end for web push
|
||||||
VAPID_PRIVATE_KEY={{vapid_private_key}}
|
VAPID_PRIVATE_KEY={{vapid_private_key}}
|
||||||
VAPID_CONTACT=mailto:you@example.com
|
VAPID_CONTACT=mailto:you@example.com
|
||||||
|
|
||||||
#Redis
|
#Redis
|
||||||
REDIS_URL={{REDIS_URL}}
|
REDIS_URL={{REDIS_URL_FOR DELAY JoBS create redis setup and add url here}}
|
||||||
TAT_TEST_MODE=false # Set to true to accelerate TAT for testing
|
TAT_TEST_MODE=false (on true it will consider 1 hour==1min)
|
||||||
|
|
||||||
# SAP Integration (OData Service via Zscaler)
|
# SAP Integration (OData Service via Zscaler)
|
||||||
SAP_BASE_URL=https://{{SAP_DOMAIN_HERE}}:{{PORT}}
|
SAP_BASE_URL=https://RENOIHND01.Eichergroup.com:1443
|
||||||
SAP_USERNAME={{SAP_USERNAME}}
|
SAP_USERNAME={{SAP_USERNAME}}
|
||||||
SAP_PASSWORD={{SAP_PASSWORD}}
|
SAP_PASSWORD={{SAP_PASSWORD}}
|
||||||
SAP_TIMEOUT_MS=30000
|
SAP_TIMEOUT_MS=30000
|
||||||
|
|||||||
@ -52,8 +52,6 @@ scrape_configs:
|
|||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
scrape_interval: 10s
|
scrape_interval: 10s
|
||||||
scrape_timeout: 5s
|
scrape_timeout: 5s
|
||||||
authorization:
|
|
||||||
credentials: 're_c92b9cf291d2be65a1704207aa25352d69432b643e6c9e9a172938c964809f2d'
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Node Exporter - Host Metrics
|
# Node Exporter - Host Metrics
|
||||||
|
|||||||
847
package-lock.json
generated
847
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
|||||||
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
|
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm install && npm run build && npm run setup && npm run start:prod",
|
"start": "npm run build && npm run start:prod && npm run setup",
|
||||||
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||||
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||||
"build": "tsc && tsc-alias",
|
"build": "tsc && tsc-alias",
|
||||||
@ -30,7 +30,6 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
"clamscan": "^2.4.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
@ -51,8 +50,6 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"puppeteer": "^24.37.2",
|
|
||||||
"sanitize-html": "^2.17.1",
|
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
@ -75,7 +72,6 @@
|
|||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'YOUR_EMAIL@{{APP_DOMAIN}}' -- ← CHANGE THIS
|
WHERE email = 'YOUR_EMAIL@royalenfield.com' -- ← CHANGE THIS
|
||||||
RETURNING
|
RETURNING
|
||||||
user_id,
|
user_id,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -21,9 +21,9 @@
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'admin@{{APP_DOMAIN}}',
|
'admin@royalenfield.com',
|
||||||
'it.admin@{{APP_DOMAIN}}',
|
'it.admin@royalenfield.com',
|
||||||
'system.admin@{{APP_DOMAIN}}'
|
'system.admin@royalenfield.com'
|
||||||
-- Add more admin emails here
|
-- Add more admin emails here
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -45,9 +45,9 @@ ORDER BY email;
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT'
|
SET role = 'MANAGEMENT'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'manager1@{{APP_DOMAIN}}',
|
'manager1@royalenfield.com',
|
||||||
'dept.head@{{APP_DOMAIN}}',
|
'dept.head@royalenfield.com',
|
||||||
'auditor@{{APP_DOMAIN}}'
|
'auditor@royalenfield.com'
|
||||||
-- Add more management emails here
|
-- Add more management emails here
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -162,7 +162,7 @@ SMTP_PORT=587
|
|||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=${SMTP_USER}
|
SMTP_USER=${SMTP_USER}
|
||||||
SMTP_PASSWORD=${SMTP_PASSWORD}
|
SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||||
|
|
||||||
# Vertex AI Gemini Configuration (for conclusion generation)
|
# Vertex AI Gemini Configuration (for conclusion generation)
|
||||||
# Service account credentials should be placed in ./credentials/ folder
|
# Service account credentials should be placed in ./credentials/ folder
|
||||||
@ -232,7 +232,7 @@ show_vapid_instructions() {
|
|||||||
echo " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
|
echo " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "5. The VAPID_CONTACT should be a valid mailto: URL"
|
echo "5. The VAPID_CONTACT should be a valid mailto: URL"
|
||||||
echo " Example: mailto:admin@{{APP_DOMAIN}}"
|
echo " Example: mailto:admin@royalenfield.com"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
|
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
50
src/app.ts
50
src/app.ts
@ -7,14 +7,10 @@ import { UserService } from './services/user.service';
|
|||||||
import { SSOUserData } from './types/auth.types';
|
import { SSOUserData } from './types/auth.types';
|
||||||
import { sequelize } from './config/database';
|
import { sequelize } from './config/database';
|
||||||
import { corsMiddleware } from './middlewares/cors.middleware';
|
import { corsMiddleware } from './middlewares/cors.middleware';
|
||||||
import { authenticateToken } from './middlewares/auth.middleware';
|
|
||||||
import { requireAdmin } from './middlewares/authorization.middleware';
|
|
||||||
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
||||||
import routes from './routes/index';
|
import routes from './routes/index';
|
||||||
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
||||||
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
||||||
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
|
|
||||||
import { rateLimiter } from './middlewares/rateLimiter.middleware';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Load environment variables from .env file first
|
// Load environment variables from .env file first
|
||||||
@ -27,7 +23,7 @@ const app: express.Application = express();
|
|||||||
// 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc.
|
// 1. Security middleware - Manual "Gold Standard" CSP to ensure it survives 301/404/etc.
|
||||||
// This handles a specific Express/Helmet edge case where redirects lose headers.
|
// This handles a specific Express/Helmet edge case where redirects lose headers.
|
||||||
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'local';
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
// Build connect-src dynamically
|
// Build connect-src dynamically
|
||||||
@ -40,10 +36,8 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
|
|||||||
connectSrc.push(...origins);
|
connectSrc.push(...origins);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
|
||||||
|
|
||||||
// Define strict CSP directives
|
// Define strict CSP directives
|
||||||
//: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
// CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
||||||
// even if the header is truncated in certain response types (like 301 redirects).
|
// even if the header is truncated in certain response types (like 301 redirects).
|
||||||
const directives = [
|
const directives = [
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
@ -51,13 +45,13 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
|
|||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
`connect-src ${connectSrc.join(' ')}`,
|
`connect-src ${connectSrc.join(' ')}`,
|
||||||
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='",
|
"style-src 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='",
|
||||||
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo=' 'sha256-441zG27rExd4/il+NvIqyL8zFx5XmyNQtE381kSkUJk='",
|
"style-src-elem 'self' https://fonts.googleapis.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-Od9mHMH7x2G6QuoV3hsPkDCwIyqbg2DX3F5nLeCYQBc=' 'sha256-eSB4TBEI8J+pgd6+gnmCP4Q+C+Yrx5BdjBEoPvZUzZI=' 'sha256-nzTgYzXYDNe6BAHiiI7NNlfK8n/auuOAhh2t92YvuXo='",
|
||||||
"style-src-attr 'unsafe-inline'",
|
"style-src-attr 'unsafe-inline'",
|
||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"script-src-elem 'self'",
|
"script-src-elem 'self'",
|
||||||
"script-src-attr 'none'",
|
"script-src-attr 'none'",
|
||||||
`img-src 'self' data: blob: https://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`,
|
"img-src 'self' data: blob: https://*.royalenfield.com https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com",
|
||||||
"frame-src 'self' blob: data:",
|
"frame-src 'self' blob: data:",
|
||||||
"font-src 'self' https://fonts.gstatic.com data:",
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
@ -91,17 +85,18 @@ app.use(cookieParser());
|
|||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
// Initializer for database connection (called from server.ts)
|
// Initialize database connection
|
||||||
export const initializeAppDatabase = async () => {
|
const initializeDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
await sequelize.authenticate();
|
await sequelize.authenticate();
|
||||||
console.log('✅ App database connection established');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ App database connection failed:', error);
|
console.error('❌ Database connection failed:', error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
// Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.)
|
// Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.)
|
||||||
// This allows Express to read X-Forwarded-* headers correctly
|
// This allows Express to read X-Forwarded-* headers correctly
|
||||||
// Set to true in production, false in development
|
// Set to true in production, false in development
|
||||||
@ -116,12 +111,6 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
|
|||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
// Global rate limiting disabled — nginx handles rate limiting in production
|
|
||||||
// app.use(rateLimiter);
|
|
||||||
|
|
||||||
// HTML sanitization - strip all tags from text inputs (after body parsing, before routes)
|
|
||||||
app.use(sanitizationMiddleware);
|
|
||||||
|
|
||||||
// Logging middleware
|
// Logging middleware
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
|
|
||||||
@ -129,7 +118,7 @@ app.use(morgan('combined'));
|
|||||||
app.use(metricsMiddleware);
|
app.use(metricsMiddleware);
|
||||||
|
|
||||||
// Prometheus metrics endpoint - expose metrics for scraping
|
// Prometheus metrics endpoint - expose metrics for scraping
|
||||||
app.use('/metrics', authenticateToken, requireAdmin, createMetricsRouter());
|
app.use(createMetricsRouter());
|
||||||
|
|
||||||
// Health check endpoint (before API routes)
|
// Health check endpoint (before API routes)
|
||||||
app.get('/health', (_req: express.Request, res: express.Response) => {
|
app.get('/health', (_req: express.Request, res: express.Response) => {
|
||||||
@ -146,16 +135,7 @@ app.use('/api/v1', routes);
|
|||||||
|
|
||||||
// Serve uploaded files statically
|
// Serve uploaded files statically
|
||||||
ensureUploadDir();
|
ensureUploadDir();
|
||||||
app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR));
|
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||||
|
|
||||||
// Initialize ClamAV toggle manager
|
|
||||||
import { initializeToggleFile } from './services/clamav/clamavToggleManager';
|
|
||||||
try {
|
|
||||||
initializeToggleFile();
|
|
||||||
console.log(`✅ ClamAV toggle initialized (ENABLE_CLAMAV=${process.env.ENABLE_CLAMAV || 'true'})`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('⚠️ ClamAV toggle initialization warning:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility)
|
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility)
|
||||||
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
|
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
|
||||||
@ -209,7 +189,7 @@ app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all users endpoint
|
// Get all users endpoint
|
||||||
app.get('/api/v1/users', authenticateToken, requireAdmin, async (_req: express.Request, res: express.Response): Promise<void> => {
|
app.get('/api/v1/users', async (_req: express.Request, res: express.Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const users = await userService.getAllUsers();
|
const users = await userService.getAllUsers();
|
||||||
|
|
||||||
@ -314,4 +294,4 @@ if (reactBuildPath && fs.existsSync(path.join(reactBuildPath, "index.html"))) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
@ -8,9 +8,9 @@ export const emailConfig = {
|
|||||||
pass: process.env.SMTP_PASSWORD || '',
|
pass: process.env.SMTP_PASSWORD || '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
from: process.env.EMAIL_FROM || `RE Workflow System <notifications@${process.env.APP_DOMAIN || 'royalenfield.com'}>`,
|
from: process.env.EMAIL_FROM || 'RE Workflow System <notifications@royalenfield.com>',
|
||||||
|
|
||||||
// Email templates
|
// Email templates
|
||||||
templates: {
|
templates: {
|
||||||
workflowCreated: 'workflow-created',
|
workflowCreated: 'workflow-created',
|
||||||
@ -20,7 +20,7 @@ export const emailConfig = {
|
|||||||
tatReminder: 'tat-reminder',
|
tatReminder: 'tat-reminder',
|
||||||
tatBreached: 'tat-breached',
|
tatBreached: 'tat-breached',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email settings
|
// Email settings
|
||||||
settings: {
|
settings: {
|
||||||
retryAttempts: 3,
|
retryAttempts: 3,
|
||||||
|
|||||||
@ -8,18 +8,18 @@ const ssoConfig: SSOConfig = {
|
|||||||
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
|
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
|
||||||
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
||||||
// Use only FRONTEND_URL from environment - no fallbacks
|
// Use only FRONTEND_URL from environment - no fallbacks
|
||||||
get allowedOrigins() {
|
get allowedOrigins() {
|
||||||
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||||
},
|
},
|
||||||
// Okta/Auth0 configuration for token exchange
|
// Okta/Auth0 configuration for token exchange
|
||||||
get oktaDomain() { return process.env.OKTA_DOMAIN || `{{IDP_DOMAIN}}`; },
|
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; },
|
||||||
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
||||||
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
||||||
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
|
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
|
||||||
// Tanflow configuration for token exchange
|
// Tanflow configuration for token exchange
|
||||||
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || `{{IDP_DOMAIN}}/realms/RE`; },
|
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; },
|
||||||
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
||||||
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || `{{TANFLOW_CLIENT_SECRET}}`; },
|
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; },
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ssoConfig };
|
export { ssoConfig };
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { ApiTokenService } from '../services/apiToken.service';
|
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
|
||||||
import { AuthenticatedRequest } from '../types/express';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const createTokenSchema = z.object({
|
|
||||||
name: z.string().min(1).max(100),
|
|
||||||
expiresInDays: z.number().int().positive().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export class ApiTokenController {
|
|
||||||
private apiTokenService: ApiTokenService;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.apiTokenService = new ApiTokenService();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new API Token
|
|
||||||
*/
|
|
||||||
async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const validation = createTokenSchema.safeParse(req.body);
|
|
||||||
if (!validation.success) {
|
|
||||||
ResponseHandler.error(res, 'Validation error', 400, validation.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, expiresInDays } = validation.data;
|
|
||||||
const userId = req.user.userId;
|
|
||||||
|
|
||||||
const result = await this.apiTokenService.createToken(userId, name, expiresInDays);
|
|
||||||
|
|
||||||
ResponseHandler.success(res, {
|
|
||||||
token: result.token,
|
|
||||||
apiToken: result.apiToken
|
|
||||||
}, 'API Token created successfully. Please copy the token now, you will not be able to see it again.');
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
ResponseHandler.error(res, 'Failed to create API token', 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List user's API Tokens
|
|
||||||
*/
|
|
||||||
async list(req: AuthenticatedRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const userId = req.user.userId;
|
|
||||||
const tokens = await this.apiTokenService.listTokens(userId);
|
|
||||||
ResponseHandler.success(res, { tokens }, 'API Tokens retrieved successfully');
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
ResponseHandler.error(res, 'Failed to list API tokens', 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke an API Token
|
|
||||||
*/
|
|
||||||
async revoke(req: AuthenticatedRequest, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const userId = req.user.userId;
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const success = await this.apiTokenService.revokeToken(userId, id);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
ResponseHandler.success(res, null, 'API Token revoked successfully');
|
|
||||||
} else {
|
|
||||||
ResponseHandler.notFound(res, 'Token not found or already revoked');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
ResponseHandler.error(res, 'Failed to revoke API token', 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -132,13 +132,10 @@ export class AuthController {
|
|||||||
|
|
||||||
// Set new access token in cookie if using cookie-based auth
|
// Set new access token in cookie if using cookie-based auth
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
|
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' is safer and works on same-domain
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -151,7 +148,7 @@ export class AuthController {
|
|||||||
message: 'Token refreshed successfully'
|
message: 'Token refreshed successfully'
|
||||||
}, 'Token refreshed successfully');
|
}, 'Token refreshed successfully');
|
||||||
} else {
|
} else {
|
||||||
// Dev: Include token for debugging
|
// Development: Include token for debugging
|
||||||
ResponseHandler.success(res, {
|
ResponseHandler.success(res, {
|
||||||
accessToken: newAccessToken
|
accessToken: newAccessToken
|
||||||
}, 'Token refreshed successfully');
|
}, 'Token refreshed successfully');
|
||||||
@ -209,13 +206,10 @@ export class AuthController {
|
|||||||
|
|
||||||
// Set tokens in httpOnly cookies (production) or return in body (development)
|
// Set tokens in httpOnly cookies (production) or return in body (development)
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
sameSite: isProduction ? ('lax' as const) : ('lax' as const),
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
@ -262,13 +256,10 @@ export class AuthController {
|
|||||||
|
|
||||||
// Set new access token in cookie
|
// Set new access token in cookie
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: isSecureEnv ? ('lax' as const) : ('lax' as const),
|
sameSite: isProduction ? ('lax' as const) : ('lax' as const),
|
||||||
maxAge: 24 * 60 * 60 * 1000,
|
maxAge: 24 * 60 * 60 * 1000,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
@ -302,16 +293,13 @@ export class AuthController {
|
|||||||
|
|
||||||
// Helper function to clear cookies with all possible option combinations
|
// Helper function to clear cookies with all possible option combinations
|
||||||
const clearCookiesCompletely = () => {
|
const clearCookiesCompletely = () => {
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
const cookieNames = ['accessToken', 'refreshToken'];
|
const cookieNames = ['accessToken', 'refreshToken'];
|
||||||
|
|
||||||
// Get the EXACT options used when setting cookies (from exchangeToken)
|
// Get the EXACT options used when setting cookies (from exchangeToken)
|
||||||
// These MUST match exactly: httpOnly, secure, sameSite, path
|
// These MUST match exactly: httpOnly, secure, sameSite, path
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
@ -481,13 +469,10 @@ export class AuthController {
|
|||||||
|
|
||||||
// Set cookies for web clients
|
// Set cookies for web clients
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const,
|
sameSite: isProduction ? 'lax' as const : 'lax' as const,
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -564,13 +549,10 @@ export class AuthController {
|
|||||||
|
|
||||||
// Set cookies with httpOnly flag for security
|
// Set cookies with httpOnly flag for security
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isUat = process.env.NODE_ENV === 'uat';
|
|
||||||
const isSecureEnv = isProduction || isUat;
|
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isSecureEnv,
|
secure: isProduction,
|
||||||
sameSite: isSecureEnv ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
|
sameSite: isProduction ? 'lax' as const : 'lax' as const, // 'lax' for same-domain
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -602,7 +584,7 @@ export class AuthController {
|
|||||||
idToken: result.oktaIdToken
|
idToken: result.oktaIdToken
|
||||||
}, 'Token exchange successful');
|
}, 'Token exchange successful');
|
||||||
} else {
|
} else {
|
||||||
// Dev: Include tokens for debugging and different-port setup
|
// Development: Include tokens for debugging and different-port setup
|
||||||
ResponseHandler.success(res, {
|
ResponseHandler.success(res, {
|
||||||
user: result.user,
|
user: result.user,
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { aiService } from '@services/ai.service';
|
|||||||
import { activityService } from '@services/activity.service';
|
import { activityService } from '@services/activity.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
import { getRequestMetadata } from '@utils/requestUtils';
|
||||||
|
import { sanitizeHtml } from '@utils/sanitizer';
|
||||||
|
|
||||||
export class ConclusionController {
|
export class ConclusionController {
|
||||||
/**
|
/**
|
||||||
@ -249,11 +249,11 @@ export class ConclusionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update conclusion
|
// Update conclusion
|
||||||
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS)
|
|
||||||
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
||||||
|
const sanitizedRemark = sanitizeHtml(finalRemark);
|
||||||
|
|
||||||
await conclusion.update({
|
await conclusion.update({
|
||||||
finalRemark: finalRemark,
|
finalRemark: sanitizedRemark,
|
||||||
editedBy: userId,
|
editedBy: userId,
|
||||||
isEdited: wasEdited,
|
isEdited: wasEdited,
|
||||||
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
|
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount
|
||||||
@ -285,7 +285,7 @@ export class ConclusionController {
|
|||||||
return res.status(400).json({ error: 'Final remark is required' });
|
return res.status(400).json({ error: 'Final remark is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: finalRemark is already sanitized by the sanitization middleware (RICH_TEXT_FIELDS)
|
const sanitizedRemark = sanitizeHtml(finalRemark);
|
||||||
|
|
||||||
// Fetch request
|
// Fetch request
|
||||||
const request = await WorkflowRequest.findOne({
|
const request = await WorkflowRequest.findOne({
|
||||||
@ -319,7 +319,7 @@ export class ConclusionController {
|
|||||||
aiGeneratedRemark: null,
|
aiGeneratedRemark: null,
|
||||||
aiModelUsed: null,
|
aiModelUsed: null,
|
||||||
aiConfidenceScore: null,
|
aiConfidenceScore: null,
|
||||||
finalRemark: finalRemark,
|
finalRemark: sanitizedRemark,
|
||||||
editedBy: userId,
|
editedBy: userId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
editCount: 0,
|
editCount: 0,
|
||||||
@ -334,7 +334,7 @@ export class ConclusionController {
|
|||||||
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
const wasEdited = (conclusion as any).aiGeneratedRemark !== finalRemark;
|
||||||
|
|
||||||
await conclusion.update({
|
await conclusion.update({
|
||||||
finalRemark: finalRemark,
|
finalRemark: sanitizedRemark,
|
||||||
editedBy: userId,
|
editedBy: userId,
|
||||||
isEdited: wasEdited,
|
isEdited: wasEdited,
|
||||||
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
|
editCount: wasEdited ? (conclusion as any).editCount + 1 : (conclusion as any).editCount,
|
||||||
@ -345,7 +345,7 @@ export class ConclusionController {
|
|||||||
// Update request status to CLOSED
|
// Update request status to CLOSED
|
||||||
await request.update({
|
await request.update({
|
||||||
status: 'CLOSED',
|
status: 'CLOSED',
|
||||||
conclusionRemark: finalRemark,
|
conclusionRemark: sanitizedRemark,
|
||||||
closureDate: new Date()
|
closureDate: new Date()
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Request, Response } from 'express';
|
|||||||
import type { AuthenticatedRequest } from '../types/express';
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
import { DealerClaimService } from '../services/dealerClaim.service';
|
import { DealerClaimService } from '../services/dealerClaim.service';
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
import { translateEInvoiceError } from '../utils/einvoiceErrors';
|
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { gcsStorageService } from '../services/gcsStorage.service';
|
import { gcsStorageService } from '../services/gcsStorage.service';
|
||||||
import { Document } from '../models/Document';
|
import { Document } from '../models/Document';
|
||||||
@ -12,11 +11,6 @@ import { sapIntegrationService } from '../services/sapIntegration.service';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
|
||||||
import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
|
||||||
import { ClaimInvoice } from '../models/ClaimInvoice';
|
|
||||||
import { ClaimInvoiceItem } from '../models/ClaimInvoiceItem';
|
|
||||||
import { ActivityType } from '../models/ActivityType';
|
|
||||||
|
|
||||||
export class DealerClaimController {
|
export class DealerClaimController {
|
||||||
private dealerClaimService = new DealerClaimService();
|
private dealerClaimService = new DealerClaimService();
|
||||||
@ -81,7 +75,7 @@ export class DealerClaimController {
|
|||||||
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
|
logger.warn('[DealerClaimController] Approver validation error:', { message: error.message });
|
||||||
return ResponseHandler.error(res, error.message, 400);
|
return ResponseHandler.error(res, error.message, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[DealerClaimController] Error creating claim request:', error);
|
logger.error('[DealerClaimController] Error creating claim request:', error);
|
||||||
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
|
return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage);
|
||||||
@ -307,7 +301,7 @@ export class DealerClaimController {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: fileBuffer,
|
buffer: fileBuffer,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
@ -366,7 +360,7 @@ export class DealerClaimController {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: fileBuffer,
|
buffer: fileBuffer,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
@ -426,7 +420,7 @@ export class DealerClaimController {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from(''));
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: fileBuffer,
|
buffer: fileBuffer,
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
@ -486,7 +480,7 @@ export class DealerClaimController {
|
|||||||
try {
|
try {
|
||||||
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
|
const fileBuffer = attendanceSheetFile.buffer || (attendanceSheetFile.path ? fs.readFileSync(attendanceSheetFile.path) : Buffer.from(''));
|
||||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||||
|
|
||||||
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
const uploadResult = await gcsStorageService.uploadFileWithFallback({
|
||||||
buffer: fileBuffer,
|
buffer: fileBuffer,
|
||||||
originalName: attendanceSheetFile.originalname,
|
originalName: attendanceSheetFile.originalname,
|
||||||
@ -567,18 +561,18 @@ export class DealerClaimController {
|
|||||||
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async validateIO(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { ioNumber } = req.query;
|
const { ioNumber } = req.query;
|
||||||
|
|
||||||
if (!ioNumber || typeof ioNumber !== 'string') {
|
if (!ioNumber || typeof ioNumber !== 'string') {
|
||||||
return ResponseHandler.error(res, 'IO number is required', 400);
|
return ResponseHandler.error(res, 'IO number is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch IO details from SAP (will return mock data until SAP is integrated)
|
// Fetch IO details from SAP (will return mock data until SAP is integrated)
|
||||||
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
|
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber.trim());
|
||||||
|
|
||||||
if (!ioValidation.isValid) {
|
if (!ioValidation.isValid) {
|
||||||
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
|
return ResponseHandler.error(res, ioValidation.error || 'Invalid IO number', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseHandler.success(res, {
|
return ResponseHandler.success(res, {
|
||||||
ioNumber: ioValidation.ioNumber,
|
ioNumber: ioValidation.ioNumber,
|
||||||
availableBalance: ioValidation.availableBalance,
|
availableBalance: ioValidation.availableBalance,
|
||||||
@ -629,7 +623,7 @@ export class DealerClaimController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
const blockAmount = blockedAmount ? parseFloat(blockedAmount) : 0;
|
||||||
|
|
||||||
// Log received data for debugging
|
// Log received data for debugging
|
||||||
logger.info('[DealerClaimController] updateIODetails received:', {
|
logger.info('[DealerClaimController] updateIODetails received:', {
|
||||||
requestId,
|
requestId,
|
||||||
@ -639,7 +633,7 @@ export class DealerClaimController {
|
|||||||
receivedBlockedAmount: blockedAmount, // Original value from request
|
receivedBlockedAmount: blockedAmount, // Original value from request
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
|
// Store in database when blocking amount > 0 OR when ioNumber and ioRemark are provided (for Step 3 approval)
|
||||||
if (blockAmount > 0) {
|
if (blockAmount > 0) {
|
||||||
if (availableBalance === undefined) {
|
if (availableBalance === undefined) {
|
||||||
@ -655,9 +649,9 @@ export class DealerClaimController {
|
|||||||
blockedAmount: blockAmount,
|
blockedAmount: blockAmount,
|
||||||
// remainingBalance will be calculated by the service from SAP's response
|
// remainingBalance will be calculated by the service from SAP's response
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
|
logger.info('[DealerClaimController] Calling updateIODetails service with:', ioData);
|
||||||
|
|
||||||
await this.dealerClaimService.updateIODetails(
|
await this.dealerClaimService.updateIODetails(
|
||||||
requestId,
|
requestId,
|
||||||
ioData,
|
ioData,
|
||||||
@ -666,7 +660,7 @@ export class DealerClaimController {
|
|||||||
|
|
||||||
// Fetch and return the updated IO details from database
|
// Fetch and return the updated IO details from database
|
||||||
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
|
const updatedIO = await InternalOrder.findOne({ where: { requestId } });
|
||||||
|
|
||||||
if (updatedIO) {
|
if (updatedIO) {
|
||||||
return ResponseHandler.success(res, {
|
return ResponseHandler.success(res, {
|
||||||
message: 'IO blocked successfully in SAP',
|
message: 'IO blocked successfully in SAP',
|
||||||
@ -757,66 +751,7 @@ export class DealerClaimController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('[DealerClaimController] Error updating e-invoice:', error);
|
logger.error('[DealerClaimController] Error updating e-invoice:', error);
|
||||||
|
return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage);
|
||||||
// Translate technical PWC/IRP error codes to user-friendly messages
|
|
||||||
const userFacingMessage = translateEInvoiceError(errorMessage);
|
|
||||||
|
|
||||||
return ResponseHandler.error(res, userFacingMessage, 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download E-Invoice PDF
|
|
||||||
* GET /api/v1/dealer-claims/:requestId/e-invoice/pdf
|
|
||||||
*/
|
|
||||||
async downloadInvoicePdf(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const identifier = req.params.requestId; // Can be UUID or requestNumber
|
|
||||||
|
|
||||||
// Find workflow to get actual UUID
|
|
||||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
|
||||||
if (!workflow) {
|
|
||||||
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
|
||||||
if (!requestId) {
|
|
||||||
return ResponseHandler.error(res, 'Invalid workflow request', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ClaimInvoice } = await import('../models/ClaimInvoice');
|
|
||||||
let invoice = await ClaimInvoice.findOne({ where: { requestId } });
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
return ResponseHandler.error(res, 'Invoice record not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate PDF on the fly
|
|
||||||
try {
|
|
||||||
const { pdfService } = await import('../services/pdf.service');
|
|
||||||
const pdfBuffer = await pdfService.generateInvoicePdf(requestId);
|
|
||||||
|
|
||||||
const requestNumber = workflow.requestNumber || 'invoice';
|
|
||||||
const fileName = `Invoice_${requestNumber}.pdf`;
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
|
|
||||||
res.setHeader('Content-Length', pdfBuffer.length);
|
|
||||||
|
|
||||||
// Convert Buffer to stream
|
|
||||||
const { Readable } = await import('stream');
|
|
||||||
const stream = new Readable();
|
|
||||||
stream.push(pdfBuffer);
|
|
||||||
stream.push(null);
|
|
||||||
stream.pipe(res);
|
|
||||||
} catch (pdfError) {
|
|
||||||
logger.error(`[DealerClaimController] Failed to generate PDF:`, pdfError);
|
|
||||||
return ResponseHandler.error(res, 'Failed to generate invoice PDF', 500);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
logger.error('[DealerClaimController] Error downloading invoice PDF:', error);
|
|
||||||
return ResponseHandler.error(res, 'Failed to download invoice PDF', 500, errorMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -940,7 +875,7 @@ export class DealerClaimController {
|
|||||||
|
|
||||||
// First validate IO number
|
// First validate IO number
|
||||||
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
|
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
|
||||||
|
|
||||||
if (!ioValidation.isValid) {
|
if (!ioValidation.isValid) {
|
||||||
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
|
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
|
||||||
}
|
}
|
||||||
@ -988,98 +923,5 @@ export class DealerClaimController {
|
|||||||
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
|
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Download Invoice CSV
|
|
||||||
* GET /api/v1/dealer-claims/:requestId/e-invoice/csv
|
|
||||||
*/
|
|
||||||
async downloadInvoiceCsv(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const identifier = req.params.requestId;
|
|
||||||
|
|
||||||
// Use helper to find workflow
|
|
||||||
const workflow = await this.findWorkflowByIdentifier(identifier);
|
|
||||||
if (!workflow) {
|
|
||||||
return ResponseHandler.error(res, 'Workflow request not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = (workflow as any).requestId || (workflow as any).request_id;
|
|
||||||
const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number;
|
|
||||||
|
|
||||||
// Fetch related data
|
|
||||||
logger.info(`[DealerClaimController] Preparing CSV for requestId: ${requestId}`);
|
|
||||||
const [invoice, items, claimDetails, internalOrder] = await Promise.all([
|
|
||||||
ClaimInvoice.findOne({ where: { requestId } }),
|
|
||||||
ClaimInvoiceItem.findAll({ where: { requestId }, order: [['slNo', 'ASC']] }),
|
|
||||||
DealerClaimDetails.findOne({ where: { requestId } }),
|
|
||||||
InternalOrder.findOne({ where: { requestId } })
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.info(`[DealerClaimController] Found ${items.length} items to export for request ${requestNumber}`);
|
|
||||||
|
|
||||||
let sapRefNo = '';
|
|
||||||
let taxationType = 'GST';
|
|
||||||
if (claimDetails?.activityType) {
|
|
||||||
const activity = await ActivityType.findOne({ where: { title: claimDetails.activityType } });
|
|
||||||
sapRefNo = activity?.sapRefNo || '';
|
|
||||||
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct CSV
|
|
||||||
const headers = [
|
|
||||||
'TRNS_UNIQ_NO',
|
|
||||||
'CLAIM_NUMBER',
|
|
||||||
'INV_NUMBER',
|
|
||||||
'DEALER_CODE',
|
|
||||||
'IO_NUMBER',
|
|
||||||
'CLAIM_DOC_TYP',
|
|
||||||
'CLAIM_DATE',
|
|
||||||
'CLAIM_AMT',
|
|
||||||
'GST_AMT',
|
|
||||||
'GST_PERCENTAG'
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = items.map(item => {
|
|
||||||
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
|
||||||
|
|
||||||
// For Non-GST, we hide HSN (often stored in transactionCode) and GST details
|
|
||||||
const trnsUniqNo = isNonGst ? '' : (item.transactionCode || '');
|
|
||||||
const claimNumber = requestNumber;
|
|
||||||
const invNumber = invoice?.invoiceNumber || '';
|
|
||||||
const dealerCode = claimDetails?.dealerCode || '';
|
|
||||||
const ioNumber = internalOrder?.ioNumber || '';
|
|
||||||
const claimDocTyp = sapRefNo;
|
|
||||||
const claimDate = invoice?.createdAt ? new Date(invoice.createdAt).toISOString().split('T')[0] : '';
|
|
||||||
const claimAmt = item.assAmt;
|
|
||||||
|
|
||||||
// Zero out tax for Non-GST
|
|
||||||
const totalTax = isNonGst ? 0 : (Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0) + Number(item.utgstAmt || 0));
|
|
||||||
const gstPercentag = isNonGst ? 0 : (item.gstRt || 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
trnsUniqNo,
|
|
||||||
claimNumber,
|
|
||||||
invNumber,
|
|
||||||
dealerCode,
|
|
||||||
ioNumber,
|
|
||||||
claimDocTyp,
|
|
||||||
claimDate,
|
|
||||||
claimAmt,
|
|
||||||
totalTax.toFixed(2),
|
|
||||||
gstPercentag
|
|
||||||
].join(',');
|
|
||||||
});
|
|
||||||
|
|
||||||
const csvContent = [headers.join(','), ...rows].join('\n');
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="Invoice_${requestNumber}.csv"`);
|
|
||||||
|
|
||||||
res.status(200).send(csvContent);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
logger.error('[DealerClaimController] Error downloading invoice CSV:', error);
|
|
||||||
return ResponseHandler.error(res, 'Failed to download invoice CSV', 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { dealerExternalService } from '../services/dealerExternal.service';
|
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
|
||||||
import logger from '../utils/logger';
|
|
||||||
|
|
||||||
export class DealerExternalController {
|
|
||||||
/**
|
|
||||||
* Search dealer by code via external API
|
|
||||||
* GET /api/v1/dealers-external/search/:dealerCode
|
|
||||||
*/
|
|
||||||
async searchByDealerCode(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { dealerCode } = req.params;
|
|
||||||
|
|
||||||
if (!dealerCode) {
|
|
||||||
return ResponseHandler.error(res, 'Dealer code is required', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dealerInfo = await dealerExternalService.getDealerByCode(dealerCode);
|
|
||||||
|
|
||||||
if (!dealerInfo) {
|
|
||||||
return ResponseHandler.error(res, 'Dealer not found in external system', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseHandler.success(res, dealerInfo, 'Dealer found successfully');
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
logger.error(`[DealerExternalController] Error searching dealer ${req.params.dealerCode}:`, error);
|
|
||||||
return ResponseHandler.error(res, 'Failed to fetch dealer from external source', 500, errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dealerExternalController = new DealerExternalController();
|
|
||||||
@ -10,37 +10,13 @@ export class UserController {
|
|||||||
this.userService = new UserService();
|
this.userService = new UserService();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllUsers(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const users = await this.userService.getAllUsers();
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
users: users.map(u => ({
|
|
||||||
userId: u.userId,
|
|
||||||
email: u.email,
|
|
||||||
displayName: u.displayName,
|
|
||||||
department: u.department,
|
|
||||||
designation: u.designation,
|
|
||||||
isActive: u.isActive,
|
|
||||||
})),
|
|
||||||
total: users.length
|
|
||||||
};
|
|
||||||
|
|
||||||
ResponseHandler.success(res, result, 'All users fetched');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch all users', { error });
|
|
||||||
ResponseHandler.error(res, 'Failed to fetch all users', 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchUsers(req: Request, res: Response): Promise<void> {
|
async searchUsers(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const q = String(req.query.q || '').trim();
|
const q = String(req.query.q || '').trim();
|
||||||
const limit = Number(req.query.limit || 10);
|
const limit = Number(req.query.limit || 10);
|
||||||
const source = String(req.query.source || 'default') as 'local' | 'okta' | 'default';
|
|
||||||
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
|
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
|
||||||
|
|
||||||
const users = await this.userService.searchUsers(q, limit, currentUserId, source);
|
const users = await this.userService.searchUsers(q, limit, currentUserId);
|
||||||
|
|
||||||
const result = users.map(u => ({
|
const result = users.map(u => ({
|
||||||
userId: (u as any).userId,
|
userId: (u as any).userId,
|
||||||
@ -93,31 +69,6 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserById(req: Request, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { userId } = req.params;
|
|
||||||
const user = await this.userService.getUserById(userId);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
ResponseHandler.error(res, 'User not found', 404);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseHandler.success(res, {
|
|
||||||
userId: user.userId,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.displayName,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
department: user.department,
|
|
||||||
isActive: user.isActive
|
|
||||||
}, 'User fetched');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch user by ID', { error });
|
|
||||||
ResponseHandler.error(res, 'Failed to fetch user by ID', 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure user exists in database (create if not exists)
|
* Ensure user exists in database (create if not exists)
|
||||||
* Called when user is selected/tagged in the frontend
|
* Called when user is selected/tagged in the frontend
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
import { getRequestMetadata } from '@utils/requestUtils';
|
||||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator, validateDealerUser } from '@services/userEnrichment.service';
|
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
|
||||||
import { DealerClaimService } from '@services/dealerClaim.service';
|
import { DealerClaimService } from '@services/dealerClaim.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
@ -27,15 +27,6 @@ export class WorkflowController {
|
|||||||
// Validate initiator exists
|
// Validate initiator exists
|
||||||
await validateInitiator(req.user.userId);
|
await validateInitiator(req.user.userId);
|
||||||
|
|
||||||
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
|
|
||||||
const dealerCode = req.body.dealerCode || (req.body as any).dealer_code;
|
|
||||||
if (dealerCode || validatedData.templateType === 'DEALER CLAIM') {
|
|
||||||
if (!dealerCode) {
|
|
||||||
throw new Error('Dealer code is required for dealer claim requests');
|
|
||||||
}
|
|
||||||
await validateDealerUser(dealerCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
|
// Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility
|
||||||
let approvalLevels = validatedData.approvalLevels || [];
|
let approvalLevels = validatedData.approvalLevels || [];
|
||||||
if (!approvalLevels.length && (req.body as any).approvers) {
|
if (!approvalLevels.length && (req.body as any).approvers) {
|
||||||
@ -179,15 +170,6 @@ export class WorkflowController {
|
|||||||
// Validate initiator exists
|
// Validate initiator exists
|
||||||
await validateInitiator(userId);
|
await validateInitiator(userId);
|
||||||
|
|
||||||
// Dealer Validation if dealerCode is provided or it's a DEALER CLAIM
|
|
||||||
const dealerCode = parsed.dealerCode || parsed.dealer_code;
|
|
||||||
if (dealerCode || validated.templateType === 'DEALER CLAIM') {
|
|
||||||
if (!dealerCode) {
|
|
||||||
throw new Error('Dealer code is required for dealer claim requests');
|
|
||||||
}
|
|
||||||
await validateDealerUser(dealerCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the approval levels from validation (already transformed above)
|
// Use the approval levels from validation (already transformed above)
|
||||||
let approvalLevels = validated.approvalLevels || [];
|
let approvalLevels = validated.approvalLevels || [];
|
||||||
|
|
||||||
|
|||||||
@ -172,7 +172,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
|||||||
- Initiator (for record)
|
- Initiator (for record)
|
||||||
- Finance team
|
- Finance team
|
||||||
- **Template**: `creditNoteSent.template.ts` (NEW)
|
- **Template**: `creditNoteSent.template.ts` (NEW)
|
||||||
- **Status**: Required implementation
|
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
|
||||||
- **Notification Type**: `credit_note_sent`
|
- **Notification Type**: `credit_note_sent`
|
||||||
- **Data Needed**:
|
- **Data Needed**:
|
||||||
- Credit note number
|
- Credit note number
|
||||||
@ -184,7 +184,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
|||||||
- Reason for credit note
|
- Reason for credit note
|
||||||
- Download link (if available)
|
- Download link (if available)
|
||||||
- **Notes**:
|
- **Notes**:
|
||||||
- Planned for email implementation
|
- Currently has TODO comment for email implementation
|
||||||
- Critical for dealer notification
|
- Critical for dealer notification
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -246,7 +246,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
|||||||
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
|
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- When: Credit note is sent to dealer (Step 8)
|
- When: Credit note is sent to dealer (Step 8)
|
||||||
- Planned for implementation
|
- Currently has TODO comment
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -255,7 +255,7 @@ This document outlines all email templates required for the Dealer Claim Managem
|
|||||||
### High Priority (Critical for Workflow)
|
### High Priority (Critical for Workflow)
|
||||||
1. **Activity Created** - Currently using generic notification, should be branded
|
1. **Activity Created** - Currently using generic notification, should be branded
|
||||||
2. **E-Invoice Generated** - Important for financial tracking
|
2. **E-Invoice Generated** - Important for financial tracking
|
||||||
3. **Credit Note Sent** - Critical for dealer notification
|
3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
|
||||||
|
|
||||||
### Medium Priority (Nice to Have)
|
### Medium Priority (Nice to Have)
|
||||||
4. **Proposal Submitted** - Better UX, but existing approval request works
|
4. **Proposal Submitted** - Better UX, but existing approval request works
|
||||||
|
|||||||
@ -991,9 +991,9 @@ Add to `.env`:
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{APP_DOMAIN}}
|
SMTP_USER=notifications@royalenfield.com
|
||||||
SMTP_PASSWORD=your-app-specific-password
|
SMTP_PASSWORD=your-app-specific-password
|
||||||
EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
|
EMAIL_FROM=RE Flow <noreply@royalenfield.com>
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_ENABLED=true
|
EMAIL_ENABLED=true
|
||||||
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
|
|||||||
EMAIL_RETRY_ATTEMPTS=3
|
EMAIL_RETRY_ATTEMPTS=3
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
BASE_URL=https://workflow.royalenfield.com
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
|
COMPANY_WEBSITE=https://www.royalenfield.com
|
||||||
SUPPORT_EMAIL=support@{{APP_DOMAIN}}
|
SUPPORT_EMAIL=support@royalenfield.com
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
|
|||||||
All templates feature a single action button:
|
All templates feature a single action button:
|
||||||
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
||||||
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
||||||
- **Example:** `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
- **Example:** `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||||
|
|
||||||
No approval/rejection buttons in emails - all actions happen within the application.
|
No approval/rejection buttons in emails - all actions happen within the application.
|
||||||
|
|
||||||
@ -231,8 +231,8 @@ SMTP_USER=your-email@domain.com
|
|||||||
SMTP_PASSWORD=your-app-password
|
SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
BASE_URL=https://workflow.royalenfield.com
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -361,7 +361,7 @@ All `[ViewDetailsLink]` placeholders should be replaced with:
|
|||||||
{baseURL}/request/{requestNumber}
|
{baseURL}/request/{requestNumber}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example: `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
Example: `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||||
|
|
||||||
### Company Name
|
### Company Name
|
||||||
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
||||||
|
|||||||
@ -12,15 +12,15 @@ emailtemplates/
|
|||||||
├── approvalRequest.template.ts ✅ Single approver email
|
├── approvalRequest.template.ts ✅ Single approver email
|
||||||
├── multiApproverRequest.template.ts ✅ Multi-approver email
|
├── multiApproverRequest.template.ts ✅ Multi-approver email
|
||||||
│
|
│
|
||||||
├── approvalConfirmation.template.ts ✅ DONE
|
├── approvalConfirmation.template.ts 🔨 TODO
|
||||||
├── rejectionNotification.template.ts ✅ DONE
|
├── rejectionNotification.template.ts 🔨 TODO
|
||||||
├── tatReminder.template.ts ✅ DONE
|
├── tatReminder.template.ts 🔨 TODO
|
||||||
├── tatBreached.template.ts ✅ DONE
|
├── tatBreached.template.ts 🔨 TODO
|
||||||
├── workflowPaused.template.ts ✅ DONE
|
├── workflowPaused.template.ts 🔨 TODO
|
||||||
├── workflowResumed.template.ts ✅ DONE
|
├── workflowResumed.template.ts 🔨 TODO
|
||||||
├── participantAdded.template.ts ✅ DONE
|
├── participantAdded.template.ts 🔨 TODO
|
||||||
├── approverSkipped.template.ts ✅ DONE
|
├── approverSkipped.template.ts 🔨 TODO
|
||||||
└── requestClosed.template.ts ✅ DONE
|
└── requestClosed.template.ts 🔨 TODO
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -53,7 +53,7 @@ const data: RequestCreatedData = {
|
|||||||
requestTime: '02:30 PM',
|
requestTime: '02:30 PM',
|
||||||
totalApprovers: 3,
|
totalApprovers: 3,
|
||||||
expectedTAT: 48,
|
expectedTAT: 48,
|
||||||
viewDetailsLink: 'https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013',
|
viewDetailsLink: 'https://workflow.royalenfield.com/request/REQ-2025-12-0013',
|
||||||
companyName: 'Royal Enfield'
|
companyName: 'Royal Enfield'
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
|
|||||||
SMTP_PASSWORD=your-app-password
|
SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
|
EMAIL_FROM=Royal Enfield Workflow <notifications@royalenfield.com>
|
||||||
|
|
||||||
# Application Settings
|
# Application Settings
|
||||||
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
BASE_URL=https://workflow.royalenfield.com
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -7,20 +7,18 @@
|
|||||||
|
|
||||||
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
||||||
|
|
||||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company Information
|
* Company Information
|
||||||
*/
|
*/
|
||||||
export const CompanyInfo = {
|
export const CompanyInfo = {
|
||||||
name: 'Royal Enfield',
|
name: 'Royal Enfield',
|
||||||
productName: 'RE Flow', // Product name displayed in header
|
productName: 'RE Flow', // Product name displayed in header
|
||||||
website: `https://www.${appDomain}`,
|
website: 'https://www.royalenfield.com',
|
||||||
supportEmail: `support@${appDomain}`,
|
supportEmail: 'support@royalenfield.com',
|
||||||
|
|
||||||
// Logo configuration for email headers
|
// Logo configuration for email headers
|
||||||
logo: {
|
logo: {
|
||||||
url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`,
|
url: 'https://www.royalenfield.com/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp',
|
||||||
alt: 'Royal Enfield Logo',
|
alt: 'Royal Enfield Logo',
|
||||||
width: 220, // Logo width in pixels (wider for better visibility)
|
width: 220, // Logo width in pixels (wider for better visibility)
|
||||||
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export async function shouldSendEmail(
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check admin-level configuration (System Config)
|
// Step 1: Check admin-level configuration (System Config)
|
||||||
const adminEmailEnabled = await isAdminEmailEnabled(emailType);
|
const adminEmailEnabled = await isAdminEmailEnabled(emailType);
|
||||||
|
|
||||||
if (!adminEmailEnabled) {
|
if (!adminEmailEnabled) {
|
||||||
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
|
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
|
||||||
return false;
|
return false;
|
||||||
@ -57,7 +57,7 @@ export async function shouldSendEmail(
|
|||||||
|
|
||||||
// Step 2: Check user-level preferences
|
// Step 2: Check user-level preferences
|
||||||
const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
|
const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
|
||||||
|
|
||||||
if (!userEmailEnabled) {
|
if (!userEmailEnabled) {
|
||||||
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
|
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
|
||||||
return false;
|
return false;
|
||||||
@ -82,28 +82,28 @@ async function isAdminEmailEnabled(emailType: EmailNotificationType): Promise<bo
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check database configuration (admin panel setting)
|
// Step 1: Check database configuration (admin panel setting)
|
||||||
const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', '');
|
const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', '');
|
||||||
|
|
||||||
if (dbConfigValue) {
|
if (dbConfigValue) {
|
||||||
// Parse database value (it's stored as string 'true' or 'false')
|
// Parse database value (it's stored as string 'true' or 'false')
|
||||||
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
||||||
|
|
||||||
if (!dbEnabled) {
|
if (!dbEnabled) {
|
||||||
logger.info('[Email] Admin has disabled email notifications globally (from database config)');
|
logger.info('[Email] Admin has disabled email notifications globally (from database config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Email] Email notifications enabled (from database config)');
|
logger.debug('[Email] Email notifications enabled (from database config)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fall back to environment variable if database config not found
|
// Step 2: Fall back to environment variable if database config not found
|
||||||
const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
|
const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
|
||||||
|
|
||||||
if (!envEnabled) {
|
if (!envEnabled) {
|
||||||
logger.info('[Email] Admin has disabled email notifications globally (from environment variable)');
|
logger.info('[Email] Admin has disabled email notifications globally (from environment variable)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Email] Email notifications enabled (from environment variable)');
|
logger.debug('[Email] Email notifications enabled (from environment variable)');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -131,7 +131,7 @@ async function isUserEmailEnabled(userId: string, emailType: EmailNotificationTy
|
|||||||
|
|
||||||
// Check user's global email notification setting
|
// Check user's global email notification setting
|
||||||
const enabled = (user as any).emailNotificationsEnabled !== false;
|
const enabled = (user as any).emailNotificationsEnabled !== false;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
logger.info(`[Email] User ${userId} has disabled email notifications globally`);
|
logger.info(`[Email] User ${userId} has disabled email notifications globally`);
|
||||||
}
|
}
|
||||||
@ -154,7 +154,7 @@ export async function shouldSendInAppNotification(
|
|||||||
try {
|
try {
|
||||||
// Check admin config first (if SystemConfig model exists)
|
// Check admin config first (if SystemConfig model exists)
|
||||||
const adminEnabled = await isAdminInAppEnabled(notificationType);
|
const adminEnabled = await isAdminInAppEnabled(notificationType);
|
||||||
|
|
||||||
if (!adminEnabled) {
|
if (!adminEnabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -171,7 +171,7 @@ export async function shouldSendInAppNotification(
|
|||||||
|
|
||||||
// Check user's global in-app notification setting
|
// Check user's global in-app notification setting
|
||||||
const enabled = (user as any).inAppNotificationsEnabled !== false;
|
const enabled = (user as any).inAppNotificationsEnabled !== false;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
|
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
|
||||||
}
|
}
|
||||||
@ -191,20 +191,20 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check database configuration (admin panel setting)
|
// Step 1: Check database configuration (admin panel setting)
|
||||||
const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', '');
|
const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', '');
|
||||||
|
|
||||||
if (dbConfigValue) {
|
if (dbConfigValue) {
|
||||||
// Parse database value (it's stored as string 'true' or 'false')
|
// Parse database value (it's stored as string 'true' or 'false')
|
||||||
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
||||||
|
|
||||||
if (!dbEnabled) {
|
if (!dbEnabled) {
|
||||||
logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)');
|
logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Notification] In-app notifications enabled (from database config)');
|
logger.debug('[Notification] In-app notifications enabled (from database config)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fall back to environment variable if database config not found
|
// Step 2: Fall back to environment variable if database config not found
|
||||||
const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS;
|
const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS;
|
||||||
if (envValue !== undefined) {
|
if (envValue !== undefined) {
|
||||||
@ -216,15 +216,15 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
|
|||||||
logger.debug('[Notification] In-app notifications enabled (from environment variable)');
|
logger.debug('[Notification] In-app notifications enabled (from environment variable)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Final fallback to system config (defaults to true)
|
// Step 3: Final fallback to system config (defaults to true)
|
||||||
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
|
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
|
||||||
|
|
||||||
if (!adminInAppEnabled) {
|
if (!adminInAppEnabled) {
|
||||||
logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)');
|
logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Notification] In-app notifications enabled (from system config default)');
|
logger.debug('[Notification] In-app notifications enabled (from system config default)');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -270,7 +270,7 @@ export async function shouldSendEmailWithOverride(
|
|||||||
userId: string,
|
userId: string,
|
||||||
emailType: EmailNotificationType
|
emailType: EmailNotificationType
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// emails always sent (override user preference)
|
// Critical emails always sent (override user preference)
|
||||||
if (CRITICAL_EMAILS.includes(emailType)) {
|
if (CRITICAL_EMAILS.includes(emailType)) {
|
||||||
const adminEnabled = await isAdminEmailEnabled(emailType);
|
const adminEnabled = await isAdminEmailEnabled(emailType);
|
||||||
if (adminEnabled) {
|
if (adminEnabled) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import {
|
import {
|
||||||
getRequestCreatedEmail,
|
getRequestCreatedEmail,
|
||||||
getApprovalRequestEmail,
|
getApprovalRequestEmail,
|
||||||
getMultiApproverRequestEmail,
|
getMultiApproverRequestEmail,
|
||||||
@ -21,10 +21,10 @@ import {
|
|||||||
async function sendTestEmail() {
|
async function sendTestEmail() {
|
||||||
try {
|
try {
|
||||||
console.log('🚀 Creating nodemailer test account...');
|
console.log('🚀 Creating nodemailer test account...');
|
||||||
|
|
||||||
// Create a test account automatically (free, no signup needed)
|
// Create a test account automatically (free, no signup needed)
|
||||||
const testAccount = await nodemailer.createTestAccount();
|
const testAccount = await nodemailer.createTestAccount();
|
||||||
|
|
||||||
console.log('✅ Test account created:', testAccount.user);
|
console.log('✅ Test account created:', testAccount.user);
|
||||||
|
|
||||||
// Create transporter with test SMTP details
|
// Create transporter with test SMTP details
|
||||||
@ -105,7 +105,7 @@ async function sendTestEmail() {
|
|||||||
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
||||||
html: html1
|
html: html1
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 1: Request Created Email');
|
console.log('✅ Test 1: Request Created Email');
|
||||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info1));
|
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info1));
|
||||||
console.log('');
|
console.log('');
|
||||||
@ -118,7 +118,7 @@ async function sendTestEmail() {
|
|||||||
subject: '[REQ-2025-12-0013] Approval Request - Action Required',
|
subject: '[REQ-2025-12-0013] Approval Request - Action Required',
|
||||||
html: html2
|
html: html2
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 2: Approval Request Email');
|
console.log('✅ Test 2: Approval Request Email');
|
||||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info2));
|
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info2));
|
||||||
console.log('');
|
console.log('');
|
||||||
@ -131,7 +131,7 @@ async function sendTestEmail() {
|
|||||||
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
|
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
|
||||||
html: html3
|
html: html3
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Test 3: Multi-Approver Request Email');
|
console.log('✅ Test 3: Multi-Approver Request Email');
|
||||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info3));
|
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info3));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@ -8,8 +8,6 @@
|
|||||||
import { emailNotificationService } from '../services/emailNotification.service';
|
import { emailNotificationService } from '../services/emailNotification.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate real workflow scenario
|
* Simulate real workflow scenario
|
||||||
*/
|
*/
|
||||||
@ -20,7 +18,7 @@ async function testRealScenario() {
|
|||||||
// Mock user data (simulating real database records)
|
// Mock user data (simulating real database records)
|
||||||
const user10 = {
|
const user10 = {
|
||||||
userId: 'user-10-uuid',
|
userId: 'user-10-uuid',
|
||||||
email: `john.doe@${appDomain}`,
|
email: 'john.doe@royalenfield.com',
|
||||||
displayName: 'John Doe',
|
displayName: 'John Doe',
|
||||||
department: 'Engineering',
|
department: 'Engineering',
|
||||||
designation: 'Senior Engineer'
|
designation: 'Senior Engineer'
|
||||||
@ -28,7 +26,7 @@ async function testRealScenario() {
|
|||||||
|
|
||||||
const user12 = {
|
const user12 = {
|
||||||
userId: 'user-12-uuid',
|
userId: 'user-12-uuid',
|
||||||
email: `jane.smith@${appDomain}`,
|
email: 'jane.smith@royalenfield.com',
|
||||||
displayName: 'Jane Smith',
|
displayName: 'Jane Smith',
|
||||||
department: 'Management',
|
department: 'Management',
|
||||||
designation: 'Engineering Manager',
|
designation: 'Engineering Manager',
|
||||||
@ -54,7 +52,7 @@ async function testRealScenario() {
|
|||||||
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<p>Please review and approve at your earliest convenience.</p>
|
<p>Please review and approve at your earliest convenience.</p>
|
||||||
<p>For questions, contact: <a href="mailto:john.doe@${appDomain}">john.doe@${appDomain}</a></p>
|
<p>For questions, contact: <a href="mailto:john.doe@royalenfield.com">john.doe@royalenfield.com</a></p>
|
||||||
`,
|
`,
|
||||||
requestType: 'Purchase',
|
requestType: 'Purchase',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
@ -70,21 +68,21 @@ async function testRealScenario() {
|
|||||||
{
|
{
|
||||||
levelNumber: 1,
|
levelNumber: 1,
|
||||||
approverName: 'Jane Smith',
|
approverName: 'Jane Smith',
|
||||||
approverEmail: `jane.smith@${appDomain}`,
|
approverEmail: 'jane.smith@royalenfield.com',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
levelNumber: 2,
|
levelNumber: 2,
|
||||||
approverName: 'Michael Brown',
|
approverName: 'Michael Brown',
|
||||||
approverEmail: `michael.brown@${appDomain}`,
|
approverEmail: 'michael.brown@royalenfield.com',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
levelNumber: 3,
|
levelNumber: 3,
|
||||||
approverName: 'Sarah Johnson',
|
approverName: 'Sarah Johnson',
|
||||||
approverEmail: `sarah.johnson@${appDomain}`,
|
approverEmail: 'sarah.johnson@royalenfield.com',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
}
|
}
|
||||||
@ -94,7 +92,7 @@ async function testRealScenario() {
|
|||||||
console.log('─'.repeat(80));
|
console.log('─'.repeat(80));
|
||||||
console.log('📧 Test 1: Request Created Email (to Initiator - User 10)');
|
console.log('📧 Test 1: Request Created Email (to Initiator - User 10)');
|
||||||
console.log('─'.repeat(80));
|
console.log('─'.repeat(80));
|
||||||
|
|
||||||
await emailNotificationService.sendRequestCreated(
|
await emailNotificationService.sendRequestCreated(
|
||||||
requestData,
|
requestData,
|
||||||
user10,
|
user10,
|
||||||
@ -170,7 +168,7 @@ async function testRealScenario() {
|
|||||||
approvedUser12,
|
approvedUser12,
|
||||||
user10,
|
user10,
|
||||||
false, // not final approval
|
false, // not final approval
|
||||||
{ displayName: 'Michael Brown', email: `michael.brown@${appDomain}` }
|
{ displayName: 'Michael Brown', email: 'michael.brown@royalenfield.com' }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
@ -254,3 +252,4 @@ testRealScenario()
|
|||||||
console.error('❌ Test failed:', error);
|
console.error('❌ Test failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,6 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
import { ssoConfig } from '../config/sso';
|
import { ssoConfig } from '../config/sso';
|
||||||
import { ResponseHandler } from '../utils/responseHandler';
|
import { ResponseHandler } from '../utils/responseHandler';
|
||||||
import { ApiTokenService } from '../services/apiToken.service';
|
|
||||||
|
|
||||||
const apiTokenService = new ApiTokenService();
|
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -26,29 +23,6 @@ export const authenticateToken = async (
|
|||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
// Check if it's an API Token (starts with re_)
|
|
||||||
if (token && token.startsWith('re_')) {
|
|
||||||
const user = await apiTokenService.verifyToken(token);
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
ResponseHandler.unauthorized(res, 'Invalid or expired API token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach user info to request object
|
|
||||||
req.user = {
|
|
||||||
userId: user.userId,
|
|
||||||
email: user.email,
|
|
||||||
employeeId: user.employeeId || null,
|
|
||||||
role: user.role
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
|
||||||
|
|
||||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||||
if (!token && req.cookies?.accessToken) {
|
if (!token && req.cookies?.accessToken) {
|
||||||
token = req.cookies.accessToken;
|
token = req.cookies.accessToken;
|
||||||
@ -61,10 +35,10 @@ export const authenticateToken = async (
|
|||||||
|
|
||||||
// Verify JWT token
|
// Verify JWT token
|
||||||
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
// Fetch user from database to ensure they still exist and are active
|
// Fetch user from database to ensure they still exist and are active
|
||||||
const user = await User.findByPk(decoded.userId);
|
const user = await User.findByPk(decoded.userId);
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
ResponseHandler.unauthorized(res, 'User not found or inactive');
|
ResponseHandler.unauthorized(res, 'User not found or inactive');
|
||||||
return;
|
return;
|
||||||
@ -115,7 +89,7 @@ export const optionalAuth = async (
|
|||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
const decoded = jwt.verify(token, ssoConfig.jwtSecret) as JwtPayload;
|
||||||
const user = await User.findByPk(decoded.userId);
|
const user = await User.findByPk(decoded.userId);
|
||||||
|
|
||||||
if (user && user.isActive) {
|
if (user && user.isActive) {
|
||||||
req.user = {
|
req.user = {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@ -125,7 +99,7 @@ export const optionalAuth = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// For optional auth, we don't throw errors, just continue without user
|
// For optional auth, we don't throw errors, just continue without user
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import cors from 'cors';
|
|||||||
const getAllowedOrigins = (): string[] | boolean => {
|
const getAllowedOrigins = (): string[] | boolean => {
|
||||||
const frontendUrl = process.env.FRONTEND_URL;
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
// In development, if FRONTEND_URL is not set, default to localhost:3000
|
// In development, if FRONTEND_URL is not set, default to localhost:3000
|
||||||
if (!frontendUrl) {
|
if (!frontendUrl) {
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
@ -15,13 +15,13 @@ const getAllowedOrigins = (): string[] | boolean => {
|
|||||||
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
// Dev fallback: allow localhost:3000
|
// Development fallback: allow localhost:3000
|
||||||
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
||||||
console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
|
console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
|
||||||
return ['http://localhost:3000'];
|
return ['http://localhost:3000'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If FRONTEND_URL is set to '*', allow all origins
|
// If FRONTEND_URL is set to '*', allow all origins
|
||||||
if (frontendUrl === '*') {
|
if (frontendUrl === '*') {
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
@ -29,15 +29,15 @@ const getAllowedOrigins = (): string[] | boolean => {
|
|||||||
}
|
}
|
||||||
return true; // This allows any origin
|
return true; // This allows any origin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse comma-separated URLs or use single URL
|
// Parse comma-separated URLs or use single URL
|
||||||
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (origins.length === 0) {
|
if (origins.length === 0) {
|
||||||
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
|
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
|
||||||
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
|
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
||||||
return origins;
|
return origins;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,433 +0,0 @@
|
|||||||
/**
|
|
||||||
* Malware Scan Middleware
|
|
||||||
* Express middleware that intercepts file uploads, triggers ClamAV scan,
|
|
||||||
* and blocks infected files. Uses temp file approach to work with memory storage.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* multer (memory storage) → malwareScanMiddleware → controller
|
|
||||||
* ↓
|
|
||||||
* Write buffer to temp file → ClamAV scan → Delete temp file
|
|
||||||
* ↓
|
|
||||||
* Clean → attach result to req, call next()
|
|
||||||
* Infected → return 403
|
|
||||||
* Scan error → return 503 (fail-secure)
|
|
||||||
* Skipped (disabled) → log, call next()
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { scanFile, ClamScanResult } from '../services/clamav/clamavScanWrapper';
|
|
||||||
import { scanContentForXSS, ContentScanResult } from '../services/fileUpload/contentXSSScanner';
|
|
||||||
import { logSecurityEvent, SecurityEventType } from '../services/logging/securityEventLogger';
|
|
||||||
import { validateFile } from '../services/fileUpload/fileValidationService';
|
|
||||||
|
|
||||||
// ── Extend Express Request ──
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
malwareScanResult?: ClamScanResult;
|
|
||||||
contentScanResult?: ContentScanResult;
|
|
||||||
scanEventId?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Temp file helpers ──
|
|
||||||
|
|
||||||
function writeTempFile(buffer: Buffer, originalName: string): string {
|
|
||||||
const tempDir = path.join(os.tmpdir(), 'clamav-scan');
|
|
||||||
if (!fs.existsSync(tempDir)) {
|
|
||||||
fs.mkdirSync(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(originalName);
|
|
||||||
const tempPath = path.join(tempDir, `${uuidv4()}${ext}`);
|
|
||||||
fs.writeFileSync(tempPath, buffer);
|
|
||||||
return tempPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTempFile(tempPath: string): void {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempPath)) {
|
|
||||||
fs.unlinkSync(tempPath);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MalwareScan] Failed to delete temp file:', tempPath, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Middleware ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Malware scan middleware for single file uploads (multer.single)
|
|
||||||
* Works with memory storage — writes buffer to temp → scans → deletes temp
|
|
||||||
*/
|
|
||||||
export async function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
||||||
// Skip if no file uploaded
|
|
||||||
const file = req.file;
|
|
||||||
if (!file) {
|
|
||||||
console.log('[MalwareScan] No file attached — skipping scan');
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[MalwareScan] 🔒 Scanning single file: ${file.originalname} (${file.size} bytes, ${file.mimetype})`);
|
|
||||||
const scanEventId = uuidv4();
|
|
||||||
req.scanEventId = scanEventId;
|
|
||||||
|
|
||||||
// Handle the async scan
|
|
||||||
await performScan(file, scanEventId, req, res, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Malware scan middleware for multiple file uploads (multer.array / multer.fields)
|
|
||||||
* Scans all files and blocks if ANY file is infected
|
|
||||||
*/
|
|
||||||
export async function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
||||||
// Handle multer.array()
|
|
||||||
const files = req.files;
|
|
||||||
if (!files || (Array.isArray(files) && files.length === 0)) {
|
|
||||||
console.log('[MalwareScan] No files attached — skipping multi-scan');
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
console.log(`[MalwareScan] 🔒 Multi-file scan started`);
|
|
||||||
|
|
||||||
const scanEventId = uuidv4();
|
|
||||||
req.scanEventId = scanEventId;
|
|
||||||
|
|
||||||
// Handle array of files
|
|
||||||
if (Array.isArray(files)) {
|
|
||||||
await performMultiScan(files, scanEventId, req, res, next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multer.fields() — object with field names as keys
|
|
||||||
const allFiles: Express.Multer.File[] = [];
|
|
||||||
const filesObj = files as { [fieldname: string]: Express.Multer.File[] };
|
|
||||||
for (const fieldFiles of Object.values(filesObj)) {
|
|
||||||
allFiles.push(...fieldFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allFiles.length === 0) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
await performMultiScan(allFiles, scanEventId, req, res, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core scan logic ──
|
|
||||||
|
|
||||||
async function performScan(
|
|
||||||
file: Express.Multer.File,
|
|
||||||
scanEventId: string,
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<void> {
|
|
||||||
let tempPath: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns)
|
|
||||||
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
|
|
||||||
const validation = await validateFile(
|
|
||||||
file.originalname,
|
|
||||||
file.mimetype,
|
|
||||||
file.buffer || null,
|
|
||||||
file.size,
|
|
||||||
maxSizeMB,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
|
|
||||||
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
size: file.size,
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
reason: 'FILE_VALIDATION_FAILED',
|
|
||||||
errors: validation.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'FILE_VALIDATION_FAILED',
|
|
||||||
message: `File rejected: ${validation.errors[0]}`,
|
|
||||||
scanEventId,
|
|
||||||
details: {
|
|
||||||
errors: validation.errors,
|
|
||||||
warnings: validation.warnings,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validation.warnings.length > 0) {
|
|
||||||
console.log(`[MalwareScan] ⚠️ File validation warnings for "${file.originalname}": ${validation.warnings.join('; ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🟢 SANITIZATION: Update originalname with the sanitized version
|
|
||||||
if (validation.sanitizedFilename) {
|
|
||||||
file.originalname = validation.sanitizedFilename;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have a buffer (memory storage) or path (disk storage)
|
|
||||||
if (file.buffer) {
|
|
||||||
tempPath = writeTempFile(file.buffer, file.originalname);
|
|
||||||
} else if (file.path) {
|
|
||||||
tempPath = file.path;
|
|
||||||
} else {
|
|
||||||
console.warn('[MalwareScan] No file buffer or path available, skipping scan');
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. ClamAV Malware Scan
|
|
||||||
const malwareResult = await scanFile(tempPath);
|
|
||||||
req.malwareScanResult = malwareResult;
|
|
||||||
|
|
||||||
// If infected, block immediately
|
|
||||||
if (malwareResult.isInfected) {
|
|
||||||
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
size: file.size,
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
virusNames: malwareResult.virusNames,
|
|
||||||
scanDuration: malwareResult.scanDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete temp file
|
|
||||||
if (tempPath && file.buffer) {
|
|
||||||
deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'MALWARE_DETECTED',
|
|
||||||
message: 'File contains malware and was blocked',
|
|
||||||
scanEventId,
|
|
||||||
details: {
|
|
||||||
scanEngine: 'ClamAV',
|
|
||||||
signatures: malwareResult.virusNames,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If ClamAV had an error (not skipped, but failed to scan), fail-secure
|
|
||||||
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
|
|
||||||
if (tempPath && file.buffer) {
|
|
||||||
deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'SCAN_UNAVAILABLE',
|
|
||||||
message: 'Antivirus scanning is temporarily unavailable. Please try again later.',
|
|
||||||
scanEventId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Content XSS Scan
|
|
||||||
const contentToScan = file.buffer || fs.readFileSync(tempPath);
|
|
||||||
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
|
|
||||||
req.contentScanResult = contentResult;
|
|
||||||
|
|
||||||
// If XSS threats found, block
|
|
||||||
if (!contentResult.safe) {
|
|
||||||
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
threats: contentResult.threats.map(t => t.description),
|
|
||||||
severity: contentResult.severity,
|
|
||||||
scanType: contentResult.scanType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tempPath && file.buffer) {
|
|
||||||
deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'CONTENT_THREAT_DETECTED',
|
|
||||||
message: 'File contains potentially malicious content and was blocked',
|
|
||||||
scanEventId,
|
|
||||||
details: {
|
|
||||||
scanType: contentResult.scanType,
|
|
||||||
threats: contentResult.threats.map(t => ({
|
|
||||||
description: t.description,
|
|
||||||
severity: t.severity,
|
|
||||||
})),
|
|
||||||
overallSeverity: contentResult.severity,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean temp file (only if we created it)
|
|
||||||
if (tempPath && file.buffer) {
|
|
||||||
deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All clear — proceed to controller
|
|
||||||
next();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[MalwareScan] Unexpected error during scan:', error.message);
|
|
||||||
|
|
||||||
if (tempPath && file.buffer) {
|
|
||||||
deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail-secure on unexpected errors
|
|
||||||
res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'SCAN_ERROR',
|
|
||||||
message: 'An error occurred during security scanning. Please try again.',
|
|
||||||
scanEventId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performMultiScan(
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
scanEventId: string,
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
let tempPath: string | null = null;
|
|
||||||
|
|
||||||
// Step 0: Pre-scan file validation
|
|
||||||
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
|
|
||||||
const validation = await validateFile(
|
|
||||||
file.originalname,
|
|
||||||
file.mimetype,
|
|
||||||
file.buffer || null,
|
|
||||||
file.size,
|
|
||||||
maxSizeMB,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
|
|
||||||
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
reason: 'FILE_VALIDATION_FAILED',
|
|
||||||
errors: validation.errors,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'FILE_VALIDATION_FAILED',
|
|
||||||
message: `File "${file.originalname}" rejected: ${validation.errors[0]}`,
|
|
||||||
scanEventId,
|
|
||||||
details: { errors: validation.errors, warnings: validation.warnings },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🟢 SANITIZATION: Update originalname with the sanitized version for each file in the array
|
|
||||||
if (validation.sanitizedFilename) {
|
|
||||||
file.originalname = validation.sanitizedFilename;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to temp if memory storage
|
|
||||||
if (file.buffer) {
|
|
||||||
tempPath = writeTempFile(file.buffer, file.originalname);
|
|
||||||
} else if (file.path) {
|
|
||||||
tempPath = file.path;
|
|
||||||
} else {
|
|
||||||
continue; // Skip files without buffer or path
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClamAV scan
|
|
||||||
const malwareResult = await scanFile(tempPath);
|
|
||||||
|
|
||||||
if (malwareResult.isInfected) {
|
|
||||||
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
virusNames: malwareResult.virusNames,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tempPath && file.buffer) deleteTempFile(tempPath);
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'MALWARE_DETECTED',
|
|
||||||
message: `File "${file.originalname}" contains malware and was blocked`,
|
|
||||||
scanEventId,
|
|
||||||
details: {
|
|
||||||
scanEngine: 'ClamAV',
|
|
||||||
signatures: malwareResult.virusNames,
|
|
||||||
infectedFile: file.originalname,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClamAV error — fail-secure
|
|
||||||
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
|
|
||||||
if (tempPath && file.buffer) deleteTempFile(tempPath);
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'SCAN_UNAVAILABLE',
|
|
||||||
message: 'Antivirus scanning is temporarily unavailable.',
|
|
||||||
scanEventId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content XSS scan
|
|
||||||
const contentToScan = file.buffer || fs.readFileSync(tempPath);
|
|
||||||
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
|
|
||||||
|
|
||||||
if (!contentResult.safe) {
|
|
||||||
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
|
|
||||||
scanEventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
threats: contentResult.threats.map(t => t.description),
|
|
||||||
severity: contentResult.severity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tempPath && file.buffer) deleteTempFile(tempPath);
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'CONTENT_THREAT_DETECTED',
|
|
||||||
message: `File "${file.originalname}" contains potentially malicious content`,
|
|
||||||
scanEventId,
|
|
||||||
details: {
|
|
||||||
scanType: contentResult.scanType,
|
|
||||||
threats: contentResult.threats.map(t => ({
|
|
||||||
description: t.description,
|
|
||||||
severity: t.severity,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean temp file
|
|
||||||
if (tempPath && file.buffer) deleteTempFile(tempPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All files clean
|
|
||||||
next();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[MalwareScan] Error during multi-file scan:', error.message);
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
success: false,
|
|
||||||
error: 'SCAN_ERROR',
|
|
||||||
message: 'An error occurred during security scanning.',
|
|
||||||
scanEventId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -180,58 +180,6 @@ export const queueJobProcessingRate = new client.Gauge({
|
|||||||
registers: [register],
|
registers: [register],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ANTIVIRUS / SECURITY METRICS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// ClamAV scan results counter
|
|
||||||
export const antivirusScanTotal = new client.Counter({
|
|
||||||
name: 'antivirus_scan_total',
|
|
||||||
help: 'Total number of antivirus scans performed',
|
|
||||||
labelNames: ['result'], // clean, infected, error, skipped
|
|
||||||
registers: [register],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ClamAV scan duration histogram
|
|
||||||
export const antivirusScanDuration = new client.Histogram({
|
|
||||||
name: 'antivirus_scan_duration_seconds',
|
|
||||||
help: 'ClamAV scan duration in seconds',
|
|
||||||
buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
|
|
||||||
registers: [register],
|
|
||||||
});
|
|
||||||
|
|
||||||
// XSS content scan results counter
|
|
||||||
export const contentXssScanTotal = new client.Counter({
|
|
||||||
name: 'content_xss_scan_total',
|
|
||||||
help: 'Total number of content XSS scans performed',
|
|
||||||
labelNames: ['result', 'file_type'], // safe, threat
|
|
||||||
registers: [register],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ClamAV daemon status gauge (1 = up, 0 = down)
|
|
||||||
export const clamavDaemonStatus = new client.Gauge({
|
|
||||||
name: 'clamav_daemon_status',
|
|
||||||
help: 'ClamAV daemon health status (1=up, 0=down)',
|
|
||||||
registers: [register],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper functions for recording antivirus metrics
|
|
||||||
export function recordAntivirusScan(result: 'clean' | 'infected' | 'error' | 'skipped', durationMs?: number): void {
|
|
||||||
antivirusScanTotal.inc({ result });
|
|
||||||
if (durationMs !== undefined) {
|
|
||||||
antivirusScanDuration.observe(durationMs / 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordContentXssScan(result: 'safe' | 'threat', fileType: string): void {
|
|
||||||
contentXssScanTotal.inc({ result, file_type: fileType });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateClamavDaemonStatus(isUp: boolean): void {
|
|
||||||
clamavDaemonStatus.set(isUp ? 1 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MIDDLEWARE
|
// MIDDLEWARE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -323,10 +271,10 @@ export async function metricsHandler(_req: Request, res: Response): Promise<void
|
|||||||
*/
|
*/
|
||||||
export function createMetricsRouter(): Router {
|
export function createMetricsRouter(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Metrics endpoint (GET /metrics)
|
// Metrics endpoint (GET /metrics)
|
||||||
router.get('/metrics', metricsHandler);
|
router.get('/metrics', metricsHandler);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
/**
|
|
||||||
* General rate limiter — applied globally to all API routes.
|
|
||||||
* Configurable via environment variables.
|
|
||||||
*/
|
|
||||||
export const rateLimiter = rateLimit({
|
export const rateLimiter = rateLimit({
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000', 10),
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Too many requests from this IP, please try again later.',
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
@ -14,117 +10,4 @@ export const rateLimiter = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skip: (req) => req.path === '/health' || req.path === '/api/v1/health',
|
});
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stricter rate limiter for authentication routes (login, token exchange).
|
|
||||||
* Prevents brute-force attacks on auth endpoints.
|
|
||||||
*/
|
|
||||||
export const authLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 200,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many authentication attempts. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter for file upload routes.
|
|
||||||
* Prevents upload abuse / DoS.
|
|
||||||
*/
|
|
||||||
export const uploadLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 500,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many upload requests. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter for admin routes.
|
|
||||||
* Stricter to prevent admin action abuse.
|
|
||||||
*/
|
|
||||||
export const adminLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many admin requests. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter for SAP / PWC e-invoice integration routes.
|
|
||||||
* Very strict — SAP and PWC calls are expensive external API calls.
|
|
||||||
*/
|
|
||||||
export const sapLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 100,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many SAP/e-invoice requests. Please wait before trying again.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter for AI routes.
|
|
||||||
* AI calls are resource-intensive — limit accordingly.
|
|
||||||
*/
|
|
||||||
export const aiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 200,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many AI requests. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter for DMS webhook routes.
|
|
||||||
* Webhooks may come in bursts, allow higher throughput.
|
|
||||||
*/
|
|
||||||
export const webhookLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 1000,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many webhook requests. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General API rate limiter for standard routes.
|
|
||||||
* Applied to routes without a more specific limiter.
|
|
||||||
*/
|
|
||||||
export const generalApiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 2000,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
message: 'Too many requests. Please try again later.',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
});
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sanitization Middleware
|
|
||||||
* Sanitizes string inputs in req.body and req.query to prevent stored XSS.
|
|
||||||
*
|
|
||||||
* Uses TWO strategies:
|
|
||||||
* 1. STRICT — strips ALL HTML tags (for normal text fields like names, emails, titles)
|
|
||||||
* 2. PERMISSIVE — allows safe formatting tags (for rich text fields like description, message, comments)
|
|
||||||
*
|
|
||||||
* This middleware runs AFTER body parsing and BEFORE route handlers.
|
|
||||||
* File upload routes (multipart) are skipped — those are handled
|
|
||||||
* by the malwareScan middleware pipeline.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import sanitizeHtml from 'sanitize-html';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fields that intentionally store HTML from rich text editors.
|
|
||||||
* These get PERMISSIVE sanitization (safe formatting tags allowed).
|
|
||||||
* All other string fields get STRICT sanitization (all tags stripped).
|
|
||||||
*/
|
|
||||||
const RICH_TEXT_FIELDS = new Set([
|
|
||||||
'description',
|
|
||||||
'requestDescription',
|
|
||||||
'message',
|
|
||||||
'content',
|
|
||||||
'comments',
|
|
||||||
'rejectionReason',
|
|
||||||
'pauseReason',
|
|
||||||
'conclusionRemark',
|
|
||||||
'aiGeneratedRemark',
|
|
||||||
'finalRemark',
|
|
||||||
'closingRemarks',
|
|
||||||
'effectiveFinalRemark',
|
|
||||||
'keyDiscussionPoints',
|
|
||||||
'keyPoints',
|
|
||||||
'remarksText',
|
|
||||||
'remark',
|
|
||||||
'remarks',
|
|
||||||
'feedback',
|
|
||||||
'note',
|
|
||||||
'notes',
|
|
||||||
'skipReason',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Strict config: zero allowed tags, zero allowed attributes
|
|
||||||
const strictSanitizeConfig: sanitizeHtml.IOptions = {
|
|
||||||
allowedTags: [],
|
|
||||||
allowedAttributes: {},
|
|
||||||
allowedIframeHostnames: [],
|
|
||||||
disallowedTagsMode: 'discard',
|
|
||||||
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Permissive config: allow safe formatting tags from rich text editors
|
|
||||||
// Blocks dangerous elements (script, iframe, object, embed, form, input)
|
|
||||||
const permissiveSanitizeConfig: sanitizeHtml.IOptions = {
|
|
||||||
allowedTags: [
|
|
||||||
// Text formatting
|
|
||||||
'p', 'br', 'b', 'i', 'u', 'em', 'strong', 's', 'strike', 'del', 'sub', 'sup', 'mark', 'small',
|
|
||||||
// Headings
|
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
||||||
// Lists
|
|
||||||
'ul', 'ol', 'li',
|
|
||||||
// Block elements
|
|
||||||
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
|
|
||||||
// Tables
|
|
||||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
|
||||||
// Links (href checked below)
|
|
||||||
'a',
|
|
||||||
// Images (src checked below)
|
|
||||||
'img',
|
|
||||||
],
|
|
||||||
allowedAttributes: {
|
|
||||||
'a': ['href', 'title', 'target', 'rel'],
|
|
||||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
|
||||||
'td': ['colspan', 'rowspan', 'style'],
|
|
||||||
'th': ['colspan', 'rowspan', 'style'],
|
|
||||||
'span': ['class', 'style'],
|
|
||||||
'div': ['class', 'style'],
|
|
||||||
'pre': ['class', 'style'],
|
|
||||||
'code': ['class', 'style'],
|
|
||||||
'p': ['class', 'style'],
|
|
||||||
'h1': ['class', 'style'],
|
|
||||||
'h2': ['class', 'style'],
|
|
||||||
'h3': ['class', 'style'],
|
|
||||||
'h4': ['class', 'style'],
|
|
||||||
'h5': ['class', 'style'],
|
|
||||||
'h6': ['class', 'style'],
|
|
||||||
'ul': ['class', 'style'],
|
|
||||||
'ol': ['class', 'style', 'start', 'type'],
|
|
||||||
'li': ['class', 'style'],
|
|
||||||
'blockquote': ['class', 'style'],
|
|
||||||
'table': ['class', 'style'],
|
|
||||||
},
|
|
||||||
allowedSchemes: ['http', 'https', 'mailto'],
|
|
||||||
allowedIframeHostnames: [],
|
|
||||||
disallowedTagsMode: 'discard',
|
|
||||||
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object', 'applet', 'form', 'input', 'textarea', 'select', 'button'],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively sanitize all string values in an object or array
|
|
||||||
* Uses the field key to decide strict vs permissive sanitization
|
|
||||||
*/
|
|
||||||
function sanitizeValue(value: any, fieldKey?: string): any {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const isRichTextField = fieldKey && RICH_TEXT_FIELDS.has(fieldKey);
|
|
||||||
const config = isRichTextField ? permissiveSanitizeConfig : strictSanitizeConfig;
|
|
||||||
return sanitizeHtml(value, config);
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => sanitizeValue(item, fieldKey));
|
|
||||||
}
|
|
||||||
if (value !== null && typeof value === 'object') {
|
|
||||||
return sanitizeObject(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize all string properties of an object (recursively)
|
|
||||||
* Passes the key name to sanitizeValue so it can choose the right config
|
|
||||||
*/
|
|
||||||
function sanitizeObject(obj: Record<string, any>): Record<string, any> {
|
|
||||||
const sanitized: Record<string, any> = {};
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
sanitized[key] = sanitizeValue(obj[key], key);
|
|
||||||
}
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware that sanitizes req.body and req.query
|
|
||||||
* Skips multipart/form-data requests (file uploads handled by malwareScan)
|
|
||||||
*/
|
|
||||||
export const sanitizationMiddleware = (req: Request, _res: Response, next: NextFunction): void => {
|
|
||||||
try {
|
|
||||||
// Skip multipart requests — file uploads are sanitized by the malware scan pipeline
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
if (contentType.includes('multipart/form-data')) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize req.body (POST/PUT/PATCH payloads)
|
|
||||||
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
||||||
req.body = sanitizeObject(req.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize req.query (GET query parameters) — always strict (no HTML in query params)
|
|
||||||
if (req.query && typeof req.query === 'object' && Object.keys(req.query).length > 0) {
|
|
||||||
const strictQuery: Record<string, any> = {};
|
|
||||||
for (const key of Object.keys(req.query)) {
|
|
||||||
const val = req.query[key];
|
|
||||||
if (typeof val === 'string') {
|
|
||||||
strictQuery[key] = sanitizeHtml(val, strictSanitizeConfig);
|
|
||||||
} else {
|
|
||||||
strictQuery[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.query = strictQuery as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
// If sanitization fails for any reason, don't block the request —
|
|
||||||
// downstream validation (Zod) will catch malformed input
|
|
||||||
console.warn('Sanitization middleware warning:', error instanceof Error ? error.message : 'Unknown error');
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to check if a column exists in a table
|
|
||||||
*/
|
|
||||||
async function columnExists(
|
|
||||||
queryInterface: QueryInterface,
|
|
||||||
tableName: string,
|
|
||||||
columnName: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const tableDescription = await queryInterface.describeTable(tableName);
|
|
||||||
return columnName in tableDescription;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
// 1. ActivityType
|
|
||||||
const activityCols = {
|
|
||||||
hsn_code: { type: DataTypes.STRING(20), allowNull: true },
|
|
||||||
sac_code: { type: DataTypes.STRING(20), allowNull: true },
|
|
||||||
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
gl_code: { type: DataTypes.STRING(20), allowNull: true },
|
|
||||||
credit_nature: { type: DataTypes.STRING(50), allowNull: true }
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [col, spec] of Object.entries(activityCols)) {
|
|
||||||
if (!(await columnExists(queryInterface, 'activity_types', col))) {
|
|
||||||
await queryInterface.addColumn('activity_types', col, spec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. GST Fields mapping for Multiple Tables
|
|
||||||
const gstFields = {
|
|
||||||
gst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
gst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
cgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
cgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
sgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
sgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
igst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
igst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
utgst_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
utgst_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
cess_rate: { type: DataTypes.DECIMAL(5, 2), allowNull: true },
|
|
||||||
cess_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
total_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses', 'claim_credit_notes'];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
for (const [col, spec] of Object.entries(gstFields)) {
|
|
||||||
if (!(await columnExists(queryInterface, table, col))) {
|
|
||||||
await queryInterface.addColumn(table, col, spec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing expense_date to DealerCompletionExpense
|
|
||||||
if (!(await columnExists(queryInterface, 'dealer_completion_expenses', 'expense_date'))) {
|
|
||||||
await queryInterface.addColumn('dealer_completion_expenses', 'expense_date', { type: DataTypes.DATEONLY, allowNull: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. ClaimInvoice
|
|
||||||
const invoiceCols = {
|
|
||||||
irn: { type: DataTypes.STRING(500), allowNull: true },
|
|
||||||
ack_no: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
ack_date: { type: DataTypes.DATE, allowNull: true },
|
|
||||||
signed_invoice: { type: DataTypes.TEXT, allowNull: true },
|
|
||||||
signed_invoice_url: { type: DataTypes.STRING(500), allowNull: true },
|
|
||||||
dealer_claim_number: { type: DataTypes.STRING(100), allowNull: true },
|
|
||||||
qr_code: { type: DataTypes.TEXT, allowNull: true },
|
|
||||||
qr_image: { type: DataTypes.TEXT, allowNull: true },
|
|
||||||
dealer_claim_date: { type: DataTypes.DATEONLY, allowNull: true },
|
|
||||||
billing_no: { type: DataTypes.STRING(100), allowNull: true },
|
|
||||||
billing_date: { type: DataTypes.DATEONLY, allowNull: true },
|
|
||||||
taxable_value: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
cgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
sgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
igst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
utgst_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
cess_total: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
tcs_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
round_off_amt: { type: DataTypes.DECIMAL(15, 2), allowNull: true },
|
|
||||||
place_of_supply: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
total_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
|
|
||||||
tax_value_in_words: { type: DataTypes.STRING(500), allowNull: true },
|
|
||||||
credit_nature: { type: DataTypes.STRING(100), allowNull: true },
|
|
||||||
consignor_gsin: { type: DataTypes.STRING(255), allowNull: true },
|
|
||||||
gstin_date: { type: DataTypes.DATEONLY, allowNull: true }
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [col, spec] of Object.entries(invoiceCols)) {
|
|
||||||
if (!(await columnExists(queryInterface, 'claim_invoices', col))) {
|
|
||||||
await queryInterface.addColumn('claim_invoices', col, spec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure file_path exists as 'file_path'
|
|
||||||
try {
|
|
||||||
if (!(await columnExists(queryInterface, 'claim_invoices', 'file_path'))) {
|
|
||||||
if (await columnExists(queryInterface, 'claim_invoices', 'invoice_file_path')) {
|
|
||||||
await queryInterface.renameColumn('claim_invoices', 'invoice_file_path', 'file_path');
|
|
||||||
} else {
|
|
||||||
await queryInterface.addColumn('claim_invoices', 'file_path', { type: DataTypes.STRING(500), allowNull: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silently continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
// Note: Best effort rollback (usually not recommended to drop columns in shared dev unless necessary)
|
|
||||||
await queryInterface.removeColumn('dealer_completion_expenses', 'expense_date').catch(() => { });
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
await queryInterface.addColumn('claim_invoices', 'pwc_response', {
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
await queryInterface.addColumn('claim_invoices', 'irp_response', {
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
allowNull: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
await queryInterface.removeColumn('claim_invoices', 'pwc_response');
|
|
||||||
await queryInterface.removeColumn('claim_invoices', 'irp_response');
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to check if a column exists in a table
|
|
||||||
*/
|
|
||||||
async function columnExists(
|
|
||||||
queryInterface: QueryInterface,
|
|
||||||
tableName: string,
|
|
||||||
columnName: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const tableDescription = await queryInterface.describeTable(tableName);
|
|
||||||
return columnName in tableDescription;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
|
||||||
|
|
||||||
const newColumns = {
|
|
||||||
quantity: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 },
|
|
||||||
hsn_code: { type: DataTypes.STRING(20), allowNull: true }
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
for (const [colName, colSpec] of Object.entries(newColumns)) {
|
|
||||||
if (!(await columnExists(queryInterface, table, colName))) {
|
|
||||||
await queryInterface.addColumn(table, colName, colSpec);
|
|
||||||
console.log(`Added column ${colName} to ${table}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Column ${colName} already exists in ${table}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
|
||||||
const columns = ['quantity', 'hsn_code'];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
for (const col of columns) {
|
|
||||||
await queryInterface.removeColumn(table, col).catch((err) => {
|
|
||||||
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
|
|
||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
await queryInterface.createTable('api_tokens', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
user_id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
key: 'user_id',
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: false,
|
|
||||||
comment: 'User-friendly name for the token',
|
|
||||||
},
|
|
||||||
prefix: {
|
|
||||||
type: DataTypes.STRING(10),
|
|
||||||
allowNull: false,
|
|
||||||
comment: 'First few characters of token for identification (e.g., re_1234)',
|
|
||||||
},
|
|
||||||
token_hash: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
comment: 'Bcrypt hash of the full token',
|
|
||||||
},
|
|
||||||
last_used_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
expires_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
comment: 'Optional expiration date',
|
|
||||||
},
|
|
||||||
is_active: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
defaultValue: true,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
},
|
|
||||||
updated_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Indexes
|
|
||||||
await queryInterface.addIndex('api_tokens', ['user_id']);
|
|
||||||
await queryInterface.addIndex('api_tokens', ['prefix']);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
await queryInterface.dropTable('api_tokens');
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to check if a column exists in a table
|
|
||||||
*/
|
|
||||||
async function columnExists(
|
|
||||||
queryInterface: QueryInterface,
|
|
||||||
tableName: string,
|
|
||||||
columnName: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const tableDescription = await queryInterface.describeTable(tableName);
|
|
||||||
return columnName in tableDescription;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
|
||||||
const colName = 'is_service';
|
|
||||||
const colSpec = { type: DataTypes.BOOLEAN, allowNull: true, defaultValue: false };
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
if (!(await columnExists(queryInterface, table, colName))) {
|
|
||||||
await queryInterface.addColumn(table, colName, colSpec);
|
|
||||||
console.log(`Added column ${colName} to ${table}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Column ${colName} already exists in ${table}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
const tables = ['dealer_proposal_cost_items', 'dealer_completion_expenses'];
|
|
||||||
const col = 'is_service';
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
await queryInterface.removeColumn(table, col).catch((err) => {
|
|
||||||
console.warn(`Failed to remove column ${col} from ${table}:`, err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
up: async (queryInterface: QueryInterface) => {
|
|
||||||
await queryInterface.createTable('claim_invoice_items', {
|
|
||||||
item_id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
request_id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'workflow_requests',
|
|
||||||
key: 'request_id',
|
|
||||||
},
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
invoice_number: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
transaction_code: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
sl_no: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
hsn_cd: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
qty: {
|
|
||||||
type: DataTypes.DECIMAL(15, 3),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
unit: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
unit_price: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
ass_amt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
gst_rt: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
igst_amt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
cgst_amt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
sgst_amt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
utgst_amt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
cgst_rate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
sgst_rate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
igst_rate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
utgst_rate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
tot_item_val: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
is_servc: {
|
|
||||||
type: DataTypes.STRING(1),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
expense_ids: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
},
|
|
||||||
updated_at: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add indexes
|
|
||||||
await queryInterface.addIndex('claim_invoice_items', ['request_id'], {
|
|
||||||
name: 'idx_claim_invoice_items_request_id',
|
|
||||||
});
|
|
||||||
await queryInterface.addIndex('claim_invoice_items', ['invoice_number'], {
|
|
||||||
name: 'idx_claim_invoice_items_invoice_number',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
down: async (queryInterface: QueryInterface) => {
|
|
||||||
await queryInterface.dropTable('claim_invoice_items');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { QueryInterface, DataTypes } from 'sequelize';
|
|
||||||
|
|
||||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
// 1. Add total_proposed_taxable_amount to dealer_claim_details
|
|
||||||
const dealerClaimDetailsTable = await queryInterface.describeTable('dealer_claim_details');
|
|
||||||
if (!dealerClaimDetailsTable.total_proposed_taxable_amount) {
|
|
||||||
await queryInterface.addColumn('dealer_claim_details', 'total_proposed_taxable_amount', {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
comment: 'Total taxable amount from proposal or actuals if higher'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Add taxable_closed_expenses to claim_budget_tracking
|
|
||||||
const claimBudgetTrackingTable = await queryInterface.describeTable('claim_budget_tracking');
|
|
||||||
if (!claimBudgetTrackingTable.taxable_closed_expenses) {
|
|
||||||
await queryInterface.addColumn('claim_budget_tracking', 'taxable_closed_expenses', {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
comment: 'Total taxable amount from the completion expenses'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Remove unique constraint from internal_orders to support multiple IOs
|
|
||||||
try {
|
|
||||||
// Check if the unique index exists before trying to remove it
|
|
||||||
const indexes = await queryInterface.showIndex('internal_orders');
|
|
||||||
const uniqueIndex = (indexes as any[]).find((idx: any) => idx.name === 'idx_internal_orders_request_id_unique' || (idx.fields && idx.fields[0] && idx.fields[0].attribute === 'request_id' && idx.unique));
|
|
||||||
|
|
||||||
if (uniqueIndex) {
|
|
||||||
await queryInterface.removeIndex('internal_orders', uniqueIndex.name);
|
|
||||||
|
|
||||||
// Add a non-unique index for performance
|
|
||||||
await queryInterface.addIndex('internal_orders', ['request_id'], {
|
|
||||||
name: 'idx_internal_orders_request_id'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error removing unique index from internal_orders:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
|
||||||
// Rollback logic
|
|
||||||
try {
|
|
||||||
await queryInterface.removeColumn('dealer_claim_details', 'total_proposed_taxable_amount');
|
|
||||||
await queryInterface.removeColumn('claim_budget_tracking', 'taxable_closed_expenses');
|
|
||||||
|
|
||||||
await queryInterface.removeIndex('internal_orders', 'idx_internal_orders_request_id');
|
|
||||||
await queryInterface.addIndex('internal_orders', ['request_id'], {
|
|
||||||
name: 'idx_internal_orders_request_id_unique',
|
|
||||||
unique: true
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in migration rollback:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,18 +9,13 @@ interface ActivityTypeAttributes {
|
|||||||
taxationType?: string;
|
taxationType?: string;
|
||||||
sapRefNo?: string;
|
sapRefNo?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
hsnCode?: string | null;
|
|
||||||
sacCode?: string | null;
|
|
||||||
gstRate?: number | null;
|
|
||||||
glCode?: string | null;
|
|
||||||
creditNature?: 'Commercial' | 'GST' | null;
|
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> { }
|
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
|
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
|
||||||
public activityTypeId!: string;
|
public activityTypeId!: string;
|
||||||
@ -29,11 +24,6 @@ class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAtt
|
|||||||
public taxationType?: string;
|
public taxationType?: string;
|
||||||
public sapRefNo?: string;
|
public sapRefNo?: string;
|
||||||
public isActive!: boolean;
|
public isActive!: boolean;
|
||||||
public hsnCode?: string | null;
|
|
||||||
public sacCode?: string | null;
|
|
||||||
public gstRate?: number | null;
|
|
||||||
public glCode?: string | null;
|
|
||||||
public creditNature?: 'Commercial' | 'GST' | null;
|
|
||||||
public createdBy!: string;
|
public createdBy!: string;
|
||||||
public updatedBy?: string;
|
public updatedBy?: string;
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
@ -81,31 +71,6 @@ ActivityType.init(
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
field: 'is_active'
|
field: 'is_active'
|
||||||
},
|
},
|
||||||
hsnCode: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'hsn_code'
|
|
||||||
},
|
|
||||||
sacCode: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sac_code'
|
|
||||||
},
|
|
||||||
gstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_rate'
|
|
||||||
},
|
|
||||||
glCode: {
|
|
||||||
type: DataTypes.STRING(50),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gl_code'
|
|
||||||
},
|
|
||||||
creditNature: {
|
|
||||||
type: DataTypes.ENUM('Commercial', 'GST'),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'credit_nature'
|
|
||||||
},
|
|
||||||
createdBy: {
|
createdBy: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import { DataTypes, Model, Optional } from 'sequelize';
|
|
||||||
import { sequelize } from '../config/database';
|
|
||||||
import { User } from './User';
|
|
||||||
|
|
||||||
interface ApiTokenAttributes {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
name: string;
|
|
||||||
prefix: string;
|
|
||||||
tokenHash: string;
|
|
||||||
lastUsedAt?: Date | null;
|
|
||||||
expiresAt?: Date | null;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiTokenCreationAttributes extends Optional<ApiTokenAttributes, 'id' | 'lastUsedAt' | 'expiresAt' | 'isActive' | 'createdAt' | 'updatedAt'> { }
|
|
||||||
|
|
||||||
class ApiToken extends Model<ApiTokenAttributes, ApiTokenCreationAttributes> implements ApiTokenAttributes {
|
|
||||||
public id!: string;
|
|
||||||
public userId!: string;
|
|
||||||
public name!: string;
|
|
||||||
public prefix!: string;
|
|
||||||
public tokenHash!: string;
|
|
||||||
public lastUsedAt?: Date | null;
|
|
||||||
public expiresAt?: Date | null;
|
|
||||||
public isActive!: boolean;
|
|
||||||
public createdAt!: Date;
|
|
||||||
public updatedAt!: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiToken.init(
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
},
|
|
||||||
userId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'user_id',
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
key: 'user_id',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
prefix: {
|
|
||||||
type: DataTypes.STRING(10),
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
tokenHash: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'token_hash',
|
|
||||||
},
|
|
||||||
lastUsedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'last_used_at',
|
|
||||||
},
|
|
||||||
expiresAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'expires_at',
|
|
||||||
},
|
|
||||||
isActive: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
defaultValue: true,
|
|
||||||
field: 'is_active',
|
|
||||||
allowNull: false,
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
field: 'created_at',
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
field: 'updated_at',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequelize,
|
|
||||||
modelName: 'ApiToken',
|
|
||||||
tableName: 'api_tokens',
|
|
||||||
timestamps: true,
|
|
||||||
createdAt: 'created_at',
|
|
||||||
updatedAt: 'updated_at',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Define associations
|
|
||||||
ApiToken.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
|
||||||
User.hasMany(ApiToken, { foreignKey: 'userId', as: 'apiTokens' });
|
|
||||||
|
|
||||||
export { ApiToken };
|
|
||||||
@ -29,7 +29,6 @@ interface ClaimBudgetTrackingAttributes {
|
|||||||
ioBlockedAt?: Date;
|
ioBlockedAt?: Date;
|
||||||
// Closed Expenses
|
// Closed Expenses
|
||||||
closedExpenses?: number;
|
closedExpenses?: number;
|
||||||
taxableClosedExpenses?: number;
|
|
||||||
closedExpensesSubmittedAt?: Date;
|
closedExpensesSubmittedAt?: Date;
|
||||||
// Final Claim Amount
|
// Final Claim Amount
|
||||||
finalClaimAmount?: number;
|
finalClaimAmount?: number;
|
||||||
@ -51,7 +50,7 @@ interface ClaimBudgetTrackingAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'taxableClosedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> { }
|
interface ClaimBudgetTrackingCreationAttributes extends Optional<ClaimBudgetTrackingAttributes, 'budgetId' | 'initialEstimatedBudget' | 'proposalEstimatedBudget' | 'proposalSubmittedAt' | 'approvedBudget' | 'approvedAt' | 'approvedBy' | 'ioBlockedAmount' | 'ioBlockedAt' | 'closedExpenses' | 'closedExpensesSubmittedAt' | 'finalClaimAmount' | 'finalClaimAmountApprovedAt' | 'finalClaimAmountApprovedBy' | 'creditNoteAmount' | 'creditNoteIssuedAt' | 'varianceAmount' | 'variancePercentage' | 'lastModifiedBy' | 'lastModifiedAt' | 'modificationReason' | 'budgetStatus' | 'currency' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
|
class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudgetTrackingCreationAttributes> implements ClaimBudgetTrackingAttributes {
|
||||||
public budgetId!: string;
|
public budgetId!: string;
|
||||||
@ -65,7 +64,6 @@ class ClaimBudgetTracking extends Model<ClaimBudgetTrackingAttributes, ClaimBudg
|
|||||||
public ioBlockedAmount?: number;
|
public ioBlockedAmount?: number;
|
||||||
public ioBlockedAt?: Date;
|
public ioBlockedAt?: Date;
|
||||||
public closedExpenses?: number;
|
public closedExpenses?: number;
|
||||||
public taxableClosedExpenses?: number;
|
|
||||||
public closedExpensesSubmittedAt?: Date;
|
public closedExpensesSubmittedAt?: Date;
|
||||||
public finalClaimAmount?: number;
|
public finalClaimAmount?: number;
|
||||||
public finalClaimAmountApprovedAt?: Date;
|
public finalClaimAmountApprovedAt?: Date;
|
||||||
@ -161,11 +159,6 @@ ClaimBudgetTracking.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'closed_expenses_submitted_at'
|
field: 'closed_expenses_submitted_at'
|
||||||
},
|
},
|
||||||
taxableClosedExpenses: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'taxable_closed_expenses'
|
|
||||||
},
|
|
||||||
finalClaimAmount: {
|
finalClaimAmount: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|||||||
@ -9,20 +9,7 @@ interface ClaimCreditNoteAttributes {
|
|||||||
invoiceId?: string;
|
invoiceId?: string;
|
||||||
creditNoteNumber?: string;
|
creditNoteNumber?: string;
|
||||||
creditNoteDate?: Date;
|
creditNoteDate?: Date;
|
||||||
creditNoteAmount: number;
|
creditNoteAmount?: number;
|
||||||
gstRate?: number;
|
|
||||||
gstAmt?: number;
|
|
||||||
cgstRate?: number;
|
|
||||||
cgstAmt?: number;
|
|
||||||
sgstRate?: number;
|
|
||||||
sgstAmt?: number;
|
|
||||||
igstRate?: number;
|
|
||||||
igstAmt?: number;
|
|
||||||
utgstRate?: number;
|
|
||||||
utgstAmt?: number;
|
|
||||||
cessRate?: number;
|
|
||||||
cessAmt?: number;
|
|
||||||
totalAmt?: number;
|
|
||||||
sapDocumentNumber?: string;
|
sapDocumentNumber?: string;
|
||||||
creditNoteFilePath?: string;
|
creditNoteFilePath?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@ -35,7 +22,7 @@ interface ClaimCreditNoteAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'gstRate' | 'gstAmt' | 'cgstRate' | 'cgstAmt' | 'sgstRate' | 'sgstAmt' | 'igstRate' | 'igstAmt' | 'utgstRate' | 'utgstAmt' | 'cessRate' | 'cessAmt' | 'totalAmt' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> { }
|
interface ClaimCreditNoteCreationAttributes extends Optional<ClaimCreditNoteAttributes, 'creditNoteId' | 'invoiceId' | 'creditNoteNumber' | 'creditNoteDate' | 'creditNoteAmount' | 'sapDocumentNumber' | 'creditNoteFilePath' | 'status' | 'errorMessage' | 'confirmedBy' | 'confirmedAt' | 'reason' | 'description' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
|
class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCreationAttributes> implements ClaimCreditNoteAttributes {
|
||||||
public creditNoteId!: string;
|
public creditNoteId!: string;
|
||||||
@ -43,20 +30,7 @@ class ClaimCreditNote extends Model<ClaimCreditNoteAttributes, ClaimCreditNoteCr
|
|||||||
public invoiceId?: string;
|
public invoiceId?: string;
|
||||||
public creditNoteNumber?: string;
|
public creditNoteNumber?: string;
|
||||||
public creditNoteDate?: Date;
|
public creditNoteDate?: Date;
|
||||||
public creditNoteAmount!: number;
|
public creditNoteAmount?: number;
|
||||||
public gstRate?: number;
|
|
||||||
public gstAmt?: number;
|
|
||||||
public cgstRate?: number;
|
|
||||||
public cgstAmt?: number;
|
|
||||||
public sgstRate?: number;
|
|
||||||
public sgstAmt?: number;
|
|
||||||
public igstRate?: number;
|
|
||||||
public igstAmt?: number;
|
|
||||||
public utgstRate?: number;
|
|
||||||
public utgstAmt?: number;
|
|
||||||
public cessRate?: number;
|
|
||||||
public cessAmt?: number;
|
|
||||||
public totalAmt?: number;
|
|
||||||
public sapDocumentNumber?: string;
|
public sapDocumentNumber?: string;
|
||||||
public creditNoteFilePath?: string;
|
public creditNoteFilePath?: string;
|
||||||
public status?: string;
|
public status?: string;
|
||||||
@ -112,73 +86,8 @@ ClaimCreditNote.init(
|
|||||||
},
|
},
|
||||||
creditNoteAmount: {
|
creditNoteAmount: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: false,
|
|
||||||
field: 'credit_amount'
|
|
||||||
},
|
|
||||||
gstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'gst_rate'
|
field: 'credit_amount',
|
||||||
},
|
|
||||||
gstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_amt'
|
|
||||||
},
|
|
||||||
cgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_rate'
|
|
||||||
},
|
|
||||||
cgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_amt'
|
|
||||||
},
|
|
||||||
sgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_rate'
|
|
||||||
},
|
|
||||||
sgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_amt'
|
|
||||||
},
|
|
||||||
igstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_rate'
|
|
||||||
},
|
|
||||||
igstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_amt'
|
|
||||||
},
|
|
||||||
utgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_rate'
|
|
||||||
},
|
|
||||||
utgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_amt'
|
|
||||||
},
|
|
||||||
cessRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_rate'
|
|
||||||
},
|
|
||||||
cessAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_amt'
|
|
||||||
},
|
|
||||||
totalAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'total_amt'
|
|
||||||
},
|
},
|
||||||
sapDocumentNumber: {
|
sapDocumentNumber: {
|
||||||
type: DataTypes.STRING(100),
|
type: DataTypes.STRING(100),
|
||||||
|
|||||||
@ -6,38 +6,11 @@ interface ClaimInvoiceAttributes {
|
|||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
invoiceNumber?: string;
|
invoiceNumber?: string;
|
||||||
dmsNumber?: string;
|
|
||||||
invoiceDate?: Date;
|
invoiceDate?: Date;
|
||||||
amount: number;
|
amount?: number;
|
||||||
status: string;
|
dmsNumber?: string;
|
||||||
irn?: string | null;
|
invoiceFilePath?: string;
|
||||||
ackNo?: string | null;
|
status?: string;
|
||||||
ackDate?: Date | null;
|
|
||||||
signedInvoice?: string | null;
|
|
||||||
signedInvoiceUrl?: string | null;
|
|
||||||
dealerClaimNumber?: string | null;
|
|
||||||
dealerClaimDate?: Date | null;
|
|
||||||
billingNo?: string | null;
|
|
||||||
billingDate?: Date | null;
|
|
||||||
taxableValue?: number | null;
|
|
||||||
cgstTotal?: number | null;
|
|
||||||
sgstTotal?: number | null;
|
|
||||||
igstTotal?: number | null;
|
|
||||||
utgstTotal?: number | null;
|
|
||||||
cessTotal?: number | null;
|
|
||||||
tcsAmt?: number | null;
|
|
||||||
roundOffAmt?: number | null;
|
|
||||||
placeOfSupply?: string | null;
|
|
||||||
totalValueInWords?: string | null;
|
|
||||||
taxValueInWords?: string | null;
|
|
||||||
creditNature?: string | null;
|
|
||||||
consignorGsin?: string | null;
|
|
||||||
gstinDate?: Date | null;
|
|
||||||
filePath?: string | null;
|
|
||||||
qrCode?: string | null;
|
|
||||||
qrImage?: string | null;
|
|
||||||
pwcResponse?: any;
|
|
||||||
irpResponse?: any;
|
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
generatedAt?: Date;
|
generatedAt?: Date;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -45,44 +18,17 @@ interface ClaimInvoiceAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'dmsNumber' | 'invoiceDate' | 'irn' | 'ackNo' | 'ackDate' | 'signedInvoice' | 'signedInvoiceUrl' | 'dealerClaimNumber' | 'dealerClaimDate' | 'billingNo' | 'billingDate' | 'taxableValue' | 'cgstTotal' | 'sgstTotal' | 'igstTotal' | 'utgstTotal' | 'cessTotal' | 'tcsAmt' | 'roundOffAmt' | 'placeOfSupply' | 'totalValueInWords' | 'taxValueInWords' | 'creditNature' | 'consignorGsin' | 'gstinDate' | 'filePath' | 'qrCode' | 'qrImage' | 'pwcResponse' | 'irpResponse' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> { }
|
interface ClaimInvoiceCreationAttributes extends Optional<ClaimInvoiceAttributes, 'invoiceId' | 'invoiceNumber' | 'invoiceDate' | 'amount' | 'dmsNumber' | 'invoiceFilePath' | 'status' | 'errorMessage' | 'generatedAt' | 'description' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
|
class ClaimInvoice extends Model<ClaimInvoiceAttributes, ClaimInvoiceCreationAttributes> implements ClaimInvoiceAttributes {
|
||||||
public invoiceId!: string;
|
public invoiceId!: string;
|
||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
public invoiceNumber?: string;
|
public invoiceNumber?: string;
|
||||||
public dmsNumber?: string;
|
|
||||||
public invoiceDate?: Date;
|
public invoiceDate?: Date;
|
||||||
public amount!: number;
|
public amount?: number;
|
||||||
public status!: string;
|
public dmsNumber?: string;
|
||||||
public irn?: string | null;
|
public invoiceFilePath?: string;
|
||||||
public ackNo?: string | null;
|
public status?: string;
|
||||||
public ackDate?: Date | null;
|
|
||||||
public signedInvoice?: string | null;
|
|
||||||
public signedInvoiceUrl?: string | null;
|
|
||||||
public dealerClaimNumber?: string | null;
|
|
||||||
public dealerClaimDate?: Date | null;
|
|
||||||
public billingNo?: string | null;
|
|
||||||
public billingDate?: Date | null;
|
|
||||||
public taxableValue?: number | null;
|
|
||||||
public cgstTotal?: number | null;
|
|
||||||
public sgstTotal?: number | null;
|
|
||||||
public igstTotal?: number | null;
|
|
||||||
public utgstTotal?: number | null;
|
|
||||||
public cessTotal?: number | null;
|
|
||||||
public tcsAmt?: number | null;
|
|
||||||
public roundOffAmt?: number | null;
|
|
||||||
public placeOfSupply?: string | null;
|
|
||||||
public totalValueInWords?: string | null;
|
|
||||||
public taxValueInWords?: string | null;
|
|
||||||
public creditNature?: string | null;
|
|
||||||
public consignorGsin?: string | null;
|
|
||||||
public gstinDate?: Date | null;
|
|
||||||
public filePath?: string | null;
|
|
||||||
public qrCode?: string | null;
|
|
||||||
public qrImage?: string | null;
|
|
||||||
public pwcResponse?: any;
|
|
||||||
public irpResponse?: any;
|
|
||||||
public errorMessage?: string;
|
public errorMessage?: string;
|
||||||
public generatedAt?: Date;
|
public generatedAt?: Date;
|
||||||
public description?: string;
|
public description?: string;
|
||||||
@ -115,11 +61,6 @@ ClaimInvoice.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'invoice_number',
|
field: 'invoice_number',
|
||||||
},
|
},
|
||||||
dmsNumber: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'dms_number',
|
|
||||||
},
|
|
||||||
invoiceDate: {
|
invoiceDate: {
|
||||||
type: DataTypes.DATEONLY,
|
type: DataTypes.DATEONLY,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -127,153 +68,23 @@ ClaimInvoice.init(
|
|||||||
},
|
},
|
||||||
amount: {
|
amount: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
field: 'invoice_amount',
|
field: 'invoice_amount',
|
||||||
},
|
},
|
||||||
|
dmsNumber: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'dms_number',
|
||||||
|
},
|
||||||
|
invoiceFilePath: {
|
||||||
|
type: DataTypes.STRING(500),
|
||||||
|
allowNull: true,
|
||||||
|
field: 'invoice_file_path',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.STRING(50),
|
type: DataTypes.STRING(50),
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'PENDING',
|
|
||||||
field: 'generation_status'
|
|
||||||
},
|
|
||||||
irn: {
|
|
||||||
type: DataTypes.STRING(500),
|
|
||||||
allowNull: true
|
|
||||||
},
|
|
||||||
ackNo: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'ack_no'
|
field: 'generation_status',
|
||||||
},
|
|
||||||
ackDate: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'ack_date'
|
|
||||||
},
|
|
||||||
signedInvoice: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'signed_invoice'
|
|
||||||
},
|
|
||||||
signedInvoiceUrl: {
|
|
||||||
type: DataTypes.STRING(500),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'signed_invoice_url'
|
|
||||||
},
|
|
||||||
dealerClaimNumber: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'dealer_claim_number'
|
|
||||||
},
|
|
||||||
dealerClaimDate: {
|
|
||||||
type: DataTypes.DATEONLY,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'dealer_claim_date'
|
|
||||||
},
|
|
||||||
billingNo: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'billing_no'
|
|
||||||
},
|
|
||||||
billingDate: {
|
|
||||||
type: DataTypes.DATEONLY,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'billing_date'
|
|
||||||
},
|
|
||||||
taxableValue: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'taxable_value'
|
|
||||||
},
|
|
||||||
cgstTotal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_total'
|
|
||||||
},
|
|
||||||
sgstTotal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_total'
|
|
||||||
},
|
|
||||||
igstTotal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_total'
|
|
||||||
},
|
|
||||||
utgstTotal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_total'
|
|
||||||
},
|
|
||||||
cessTotal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_total'
|
|
||||||
},
|
|
||||||
tcsAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'tcs_amt'
|
|
||||||
},
|
|
||||||
roundOffAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'round_off_amt'
|
|
||||||
},
|
|
||||||
placeOfSupply: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'place_of_supply'
|
|
||||||
},
|
|
||||||
totalValueInWords: {
|
|
||||||
type: DataTypes.STRING(500),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'total_value_in_words'
|
|
||||||
},
|
|
||||||
taxValueInWords: {
|
|
||||||
type: DataTypes.STRING(500),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'tax_value_in_words'
|
|
||||||
},
|
|
||||||
creditNature: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'credit_nature'
|
|
||||||
},
|
|
||||||
consignorGsin: {
|
|
||||||
type: DataTypes.STRING(255),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'consignor_gsin'
|
|
||||||
},
|
|
||||||
gstinDate: {
|
|
||||||
type: DataTypes.DATEONLY,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gstin_date'
|
|
||||||
},
|
|
||||||
filePath: {
|
|
||||||
type: DataTypes.STRING(500),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'file_path'
|
|
||||||
},
|
|
||||||
qrCode: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'qr_code'
|
|
||||||
},
|
|
||||||
qrImage: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'qr_image'
|
|
||||||
},
|
|
||||||
pwcResponse: {
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'pwc_response'
|
|
||||||
},
|
|
||||||
irpResponse: {
|
|
||||||
type: DataTypes.JSON,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'irp_response'
|
|
||||||
},
|
},
|
||||||
errorMessage: {
|
errorMessage: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|||||||
@ -1,231 +0,0 @@
|
|||||||
import { DataTypes, Model, Optional } from 'sequelize';
|
|
||||||
import { sequelize } from '@config/database';
|
|
||||||
import { WorkflowRequest } from './WorkflowRequest';
|
|
||||||
|
|
||||||
interface ClaimInvoiceItemAttributes {
|
|
||||||
itemId: string;
|
|
||||||
requestId: string;
|
|
||||||
invoiceNumber?: string;
|
|
||||||
transactionCode?: string;
|
|
||||||
slNo: number;
|
|
||||||
description: string;
|
|
||||||
hsnCd: string;
|
|
||||||
qty: number;
|
|
||||||
unit: string;
|
|
||||||
unitPrice: number;
|
|
||||||
assAmt: number;
|
|
||||||
gstRt: number;
|
|
||||||
igstAmt: number;
|
|
||||||
cgstAmt: number;
|
|
||||||
sgstAmt: number;
|
|
||||||
utgstAmt: number;
|
|
||||||
totItemVal: number;
|
|
||||||
isServc: string;
|
|
||||||
igstRate?: number;
|
|
||||||
cgstRate?: number;
|
|
||||||
sgstRate?: number;
|
|
||||||
utgstRate?: number;
|
|
||||||
expenseIds?: string[];
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClaimInvoiceItemCreationAttributes extends Optional<ClaimInvoiceItemAttributes, 'itemId' | 'invoiceNumber' | 'transactionCode' | 'expenseIds' | 'createdAt' | 'updatedAt' | 'utgstAmt' | 'igstRate' | 'cgstRate' | 'sgstRate' | 'utgstRate'> { }
|
|
||||||
|
|
||||||
class ClaimInvoiceItem extends Model<ClaimInvoiceItemAttributes, ClaimInvoiceItemCreationAttributes> implements ClaimInvoiceItemAttributes {
|
|
||||||
public itemId!: string;
|
|
||||||
public requestId!: string;
|
|
||||||
public invoiceNumber?: string;
|
|
||||||
public transactionCode?: string;
|
|
||||||
public slNo!: number;
|
|
||||||
public description!: string;
|
|
||||||
public hsnCd!: string;
|
|
||||||
public qty!: number;
|
|
||||||
public unit!: string;
|
|
||||||
public unitPrice!: number;
|
|
||||||
public assAmt!: number;
|
|
||||||
public gstRt!: number;
|
|
||||||
public igstAmt!: number;
|
|
||||||
public cgstAmt!: number;
|
|
||||||
public sgstAmt!: number;
|
|
||||||
public utgstAmt!: number;
|
|
||||||
public totItemVal!: number;
|
|
||||||
public isServc!: string;
|
|
||||||
public igstRate?: number;
|
|
||||||
public cgstRate?: number;
|
|
||||||
public sgstRate?: number;
|
|
||||||
public utgstRate?: number;
|
|
||||||
public expenseIds?: string[];
|
|
||||||
public createdAt!: Date;
|
|
||||||
public updatedAt!: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
ClaimInvoiceItem.init(
|
|
||||||
{
|
|
||||||
itemId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true,
|
|
||||||
field: 'item_id',
|
|
||||||
},
|
|
||||||
requestId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'request_id',
|
|
||||||
references: {
|
|
||||||
model: 'workflow_requests',
|
|
||||||
key: 'request_id',
|
|
||||||
},
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
},
|
|
||||||
invoiceNumber: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'invoice_number',
|
|
||||||
},
|
|
||||||
transactionCode: {
|
|
||||||
type: DataTypes.STRING(100),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'transaction_code',
|
|
||||||
},
|
|
||||||
slNo: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'sl_no',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'description',
|
|
||||||
},
|
|
||||||
hsnCd: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'hsn_cd',
|
|
||||||
},
|
|
||||||
qty: {
|
|
||||||
type: DataTypes.DECIMAL(15, 3),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'qty',
|
|
||||||
},
|
|
||||||
unit: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'unit',
|
|
||||||
},
|
|
||||||
unitPrice: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'unit_price',
|
|
||||||
},
|
|
||||||
assAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'ass_amt',
|
|
||||||
},
|
|
||||||
gstRt: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'gst_rt',
|
|
||||||
},
|
|
||||||
igstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'igst_amt',
|
|
||||||
},
|
|
||||||
cgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'cgst_amt',
|
|
||||||
},
|
|
||||||
sgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'sgst_amt',
|
|
||||||
},
|
|
||||||
utgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
field: 'utgst_amt',
|
|
||||||
},
|
|
||||||
igstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
field: 'igst_rate',
|
|
||||||
},
|
|
||||||
cgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
field: 'cgst_rate',
|
|
||||||
},
|
|
||||||
sgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
field: 'sgst_rate',
|
|
||||||
},
|
|
||||||
utgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 0,
|
|
||||||
field: 'utgst_rate',
|
|
||||||
},
|
|
||||||
totItemVal: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'tot_item_val',
|
|
||||||
},
|
|
||||||
isServc: {
|
|
||||||
type: DataTypes.STRING(1),
|
|
||||||
allowNull: false,
|
|
||||||
field: 'is_servc',
|
|
||||||
},
|
|
||||||
expenseIds: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
allowNull: true,
|
|
||||||
field: 'expense_ids',
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
field: 'created_at',
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: DataTypes.NOW,
|
|
||||||
field: 'updated_at',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequelize,
|
|
||||||
modelName: 'ClaimInvoiceItem',
|
|
||||||
tableName: 'claim_invoice_items',
|
|
||||||
timestamps: true,
|
|
||||||
createdAt: 'created_at',
|
|
||||||
updatedAt: 'updated_at',
|
|
||||||
indexes: [
|
|
||||||
{ fields: ['request_id'], name: 'idx_claim_invoice_items_request_id' },
|
|
||||||
{ fields: ['invoice_number'], name: 'idx_claim_invoice_items_invoice_number' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
WorkflowRequest.hasMany(ClaimInvoiceItem, {
|
|
||||||
as: 'invoiceItems',
|
|
||||||
foreignKey: 'requestId',
|
|
||||||
sourceKey: 'requestId',
|
|
||||||
});
|
|
||||||
|
|
||||||
ClaimInvoiceItem.belongsTo(WorkflowRequest, {
|
|
||||||
as: 'workflowRequest',
|
|
||||||
foreignKey: 'requestId',
|
|
||||||
targetKey: 'requestId',
|
|
||||||
});
|
|
||||||
|
|
||||||
export { ClaimInvoiceItem };
|
|
||||||
@ -11,17 +11,16 @@ interface DealerClaimDetailsAttributes {
|
|||||||
dealerName: string;
|
dealerName: string;
|
||||||
dealerEmail?: string;
|
dealerEmail?: string;
|
||||||
dealerPhone?: string;
|
dealerPhone?: string;
|
||||||
dealerAddress?: string | null;
|
dealerAddress?: string;
|
||||||
activityDate?: Date | null;
|
activityDate?: Date;
|
||||||
location?: string;
|
location?: string;
|
||||||
periodStartDate?: Date;
|
periodStartDate?: Date;
|
||||||
periodEndDate?: Date;
|
periodEndDate?: Date;
|
||||||
totalProposedTaxableAmount?: number;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'totalProposedTaxableAmount' | 'createdAt' | 'updatedAt'> { }
|
interface DealerClaimDetailsCreationAttributes extends Optional<DealerClaimDetailsAttributes, 'claimId' | 'dealerEmail' | 'dealerPhone' | 'dealerAddress' | 'activityDate' | 'location' | 'periodStartDate' | 'periodEndDate' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaimDetailsCreationAttributes> implements DealerClaimDetailsAttributes {
|
||||||
public claimId!: string;
|
public claimId!: string;
|
||||||
@ -32,12 +31,11 @@ class DealerClaimDetails extends Model<DealerClaimDetailsAttributes, DealerClaim
|
|||||||
public dealerName!: string;
|
public dealerName!: string;
|
||||||
public dealerEmail?: string;
|
public dealerEmail?: string;
|
||||||
public dealerPhone?: string;
|
public dealerPhone?: string;
|
||||||
public dealerAddress?: string | null;
|
public dealerAddress?: string;
|
||||||
public activityDate?: Date | null;
|
public activityDate?: Date;
|
||||||
public location?: string;
|
public location?: string;
|
||||||
public periodStartDate?: Date;
|
public periodStartDate?: Date;
|
||||||
public periodEndDate?: Date;
|
public periodEndDate?: Date;
|
||||||
public totalProposedTaxableAmount?: number;
|
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
@ -95,8 +93,8 @@ DealerClaimDetails.init(
|
|||||||
},
|
},
|
||||||
dealerAddress: {
|
dealerAddress: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
field: 'dealer_address',
|
allowNull: true,
|
||||||
comment: 'Dealer address'
|
field: 'dealer_address'
|
||||||
},
|
},
|
||||||
activityDate: {
|
activityDate: {
|
||||||
type: DataTypes.DATEONLY,
|
type: DataTypes.DATEONLY,
|
||||||
@ -117,12 +115,6 @@ DealerClaimDetails.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
field: 'period_end_date'
|
field: 'period_end_date'
|
||||||
},
|
},
|
||||||
totalProposedTaxableAmount: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'total_proposed_taxable_amount',
|
|
||||||
comment: 'Total taxable amount from proposal or actuals if higher'
|
|
||||||
},
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -9,28 +9,11 @@ interface DealerCompletionExpenseAttributes {
|
|||||||
completionId?: string | null;
|
completionId?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
gstRate?: number;
|
|
||||||
gstAmt?: number;
|
|
||||||
quantity?: number;
|
|
||||||
hsnCode?: string;
|
|
||||||
cgstRate?: number;
|
|
||||||
cgstAmt?: number;
|
|
||||||
sgstRate?: number;
|
|
||||||
sgstAmt?: number;
|
|
||||||
igstRate?: number;
|
|
||||||
igstAmt?: number;
|
|
||||||
utgstRate?: number;
|
|
||||||
utgstAmt?: number;
|
|
||||||
cessRate?: number;
|
|
||||||
cessAmt?: number;
|
|
||||||
totalAmt?: number;
|
|
||||||
isService?: boolean;
|
|
||||||
expenseDate: Date;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> { }
|
interface DealerCompletionExpenseCreationAttributes extends Optional<DealerCompletionExpenseAttributes, 'expenseId' | 'completionId' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
|
class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, DealerCompletionExpenseCreationAttributes> implements DealerCompletionExpenseAttributes {
|
||||||
public expenseId!: string;
|
public expenseId!: string;
|
||||||
@ -38,23 +21,6 @@ class DealerCompletionExpense extends Model<DealerCompletionExpenseAttributes, D
|
|||||||
public completionId?: string | null;
|
public completionId?: string | null;
|
||||||
public description!: string;
|
public description!: string;
|
||||||
public amount!: number;
|
public amount!: number;
|
||||||
public gstRate?: number;
|
|
||||||
public gstAmt?: number;
|
|
||||||
public quantity?: number;
|
|
||||||
public hsnCode?: string;
|
|
||||||
public cgstRate?: number;
|
|
||||||
public cgstAmt?: number;
|
|
||||||
public sgstRate?: number;
|
|
||||||
public sgstAmt?: number;
|
|
||||||
public igstRate?: number;
|
|
||||||
public igstAmt?: number;
|
|
||||||
public utgstRate?: number;
|
|
||||||
public utgstAmt?: number;
|
|
||||||
public cessRate?: number;
|
|
||||||
public cessAmt?: number;
|
|
||||||
public totalAmt?: number;
|
|
||||||
public isService?: boolean;
|
|
||||||
public expenseDate!: Date;
|
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
}
|
}
|
||||||
@ -97,93 +63,6 @@ DealerCompletionExpense.init(
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
field: 'amount',
|
field: 'amount',
|
||||||
},
|
},
|
||||||
gstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_rate'
|
|
||||||
},
|
|
||||||
gstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_amt'
|
|
||||||
},
|
|
||||||
quantity: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 1,
|
|
||||||
field: 'quantity'
|
|
||||||
},
|
|
||||||
hsnCode: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'hsn_code'
|
|
||||||
},
|
|
||||||
cgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_rate'
|
|
||||||
},
|
|
||||||
cgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_amt'
|
|
||||||
},
|
|
||||||
sgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_rate'
|
|
||||||
},
|
|
||||||
sgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_amt'
|
|
||||||
},
|
|
||||||
igstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_rate'
|
|
||||||
},
|
|
||||||
igstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_amt'
|
|
||||||
},
|
|
||||||
utgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_rate'
|
|
||||||
},
|
|
||||||
utgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_amt'
|
|
||||||
},
|
|
||||||
cessRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_rate'
|
|
||||||
},
|
|
||||||
cessAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_amt'
|
|
||||||
},
|
|
||||||
totalAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'total_amt'
|
|
||||||
},
|
|
||||||
isService: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: false,
|
|
||||||
field: 'is_service'
|
|
||||||
},
|
|
||||||
expenseDate: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false,
|
|
||||||
field: 'expense_date',
|
|
||||||
},
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -9,28 +9,12 @@ interface DealerProposalCostItemAttributes {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
itemDescription: string;
|
itemDescription: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
gstRate?: number;
|
|
||||||
gstAmt?: number;
|
|
||||||
quantity?: number;
|
|
||||||
hsnCode?: string;
|
|
||||||
cgstRate?: number;
|
|
||||||
cgstAmt?: number;
|
|
||||||
sgstRate?: number;
|
|
||||||
sgstAmt?: number;
|
|
||||||
igstRate?: number;
|
|
||||||
igstAmt?: number;
|
|
||||||
utgstRate?: number;
|
|
||||||
utgstAmt?: number;
|
|
||||||
cessRate?: number;
|
|
||||||
cessAmt?: number;
|
|
||||||
totalAmt?: number;
|
|
||||||
isService?: boolean;
|
|
||||||
itemOrder: number;
|
itemOrder: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> { }
|
interface DealerProposalCostItemCreationAttributes extends Optional<DealerProposalCostItemAttributes, 'costItemId' | 'itemOrder' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
|
class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, DealerProposalCostItemCreationAttributes> implements DealerProposalCostItemAttributes {
|
||||||
public costItemId!: string;
|
public costItemId!: string;
|
||||||
@ -38,22 +22,6 @@ class DealerProposalCostItem extends Model<DealerProposalCostItemAttributes, Dea
|
|||||||
public requestId!: string;
|
public requestId!: string;
|
||||||
public itemDescription!: string;
|
public itemDescription!: string;
|
||||||
public amount!: number;
|
public amount!: number;
|
||||||
public gstRate?: number;
|
|
||||||
public gstAmt?: number;
|
|
||||||
public quantity?: number;
|
|
||||||
public hsnCode?: string;
|
|
||||||
public cgstRate?: number;
|
|
||||||
public cgstAmt?: number;
|
|
||||||
public sgstRate?: number;
|
|
||||||
public sgstAmt?: number;
|
|
||||||
public igstRate?: number;
|
|
||||||
public igstAmt?: number;
|
|
||||||
public utgstRate?: number;
|
|
||||||
public utgstAmt?: number;
|
|
||||||
public cessRate?: number;
|
|
||||||
public cessAmt?: number;
|
|
||||||
public totalAmt?: number;
|
|
||||||
public isService?: boolean;
|
|
||||||
public itemOrder!: number;
|
public itemOrder!: number;
|
||||||
public createdAt!: Date;
|
public createdAt!: Date;
|
||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
@ -98,88 +66,6 @@ DealerProposalCostItem.init(
|
|||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
gstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_rate'
|
|
||||||
},
|
|
||||||
gstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'gst_amt'
|
|
||||||
},
|
|
||||||
quantity: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: 1,
|
|
||||||
field: 'quantity'
|
|
||||||
},
|
|
||||||
hsnCode: {
|
|
||||||
type: DataTypes.STRING(20),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'hsn_code'
|
|
||||||
},
|
|
||||||
cgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_rate'
|
|
||||||
},
|
|
||||||
cgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cgst_amt'
|
|
||||||
},
|
|
||||||
sgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_rate'
|
|
||||||
},
|
|
||||||
sgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'sgst_amt'
|
|
||||||
},
|
|
||||||
igstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_rate'
|
|
||||||
},
|
|
||||||
igstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'igst_amt'
|
|
||||||
},
|
|
||||||
utgstRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_rate'
|
|
||||||
},
|
|
||||||
utgstAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'utgst_amt'
|
|
||||||
},
|
|
||||||
cessRate: {
|
|
||||||
type: DataTypes.DECIMAL(5, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_rate'
|
|
||||||
},
|
|
||||||
cessAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'cess_amt'
|
|
||||||
},
|
|
||||||
totalAmt: {
|
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
|
||||||
allowNull: true,
|
|
||||||
field: 'total_amt'
|
|
||||||
},
|
|
||||||
isService: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
allowNull: true,
|
|
||||||
defaultValue: false,
|
|
||||||
field: 'is_service'
|
|
||||||
},
|
|
||||||
itemOrder: {
|
itemOrder: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@ -26,7 +26,7 @@ interface InternalOrderAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> { }
|
interface InternalOrderCreationAttributes extends Optional<InternalOrderAttributes, 'ioId' | 'ioRemark' | 'ioAvailableBalance' | 'ioBlockedAmount' | 'ioRemainingBalance' | 'organizedBy' | 'organizedAt' | 'sapDocumentNumber' | 'status' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
|
class InternalOrder extends Model<InternalOrderAttributes, InternalOrderCreationAttributes> implements InternalOrderAttributes {
|
||||||
public ioId!: string;
|
public ioId!: string;
|
||||||
@ -137,7 +137,7 @@ InternalOrder.init(
|
|||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
fields: ['request_id'],
|
fields: ['request_id'],
|
||||||
unique: false
|
unique: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fields: ['io_number']
|
fields: ['io_number']
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { DataTypes, Model, Optional } from 'sequelize';
|
import { DataTypes, Model, Optional } from 'sequelize';
|
||||||
import { sequelize } from '../config/database';
|
import { sequelize } from '../config/database';
|
||||||
// ApiToken association is defined in ApiToken.ts to avoid circular dependency issues
|
|
||||||
// but we can declare the mixin type here if needed.
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Role Enum
|
* User Role Enum
|
||||||
@ -24,7 +21,7 @@ interface UserAttributes {
|
|||||||
department?: string | null;
|
department?: string | null;
|
||||||
designation?: string | null;
|
designation?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|
||||||
// Extended fields from SSO/Okta (All Optional)
|
// Extended fields from SSO/Okta (All Optional)
|
||||||
manager?: string | null; // Reporting manager name
|
manager?: string | null; // Reporting manager name
|
||||||
secondEmail?: string | null; // Alternate email
|
secondEmail?: string | null; // Alternate email
|
||||||
@ -33,7 +30,7 @@ interface UserAttributes {
|
|||||||
postalAddress?: string | null; // Work location/office address
|
postalAddress?: string | null; // Work location/office address
|
||||||
mobilePhone?: string | null; // Mobile contact (different from phone)
|
mobilePhone?: string | null; // Mobile contact (different from phone)
|
||||||
adGroups?: string[] | null; // Active Directory group memberships
|
adGroups?: string[] | null; // Active Directory group memberships
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
location?: {
|
location?: {
|
||||||
city?: string;
|
city?: string;
|
||||||
@ -42,12 +39,12 @@ interface UserAttributes {
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
emailNotificationsEnabled: boolean;
|
emailNotificationsEnabled: boolean;
|
||||||
pushNotificationsEnabled: boolean;
|
pushNotificationsEnabled: boolean;
|
||||||
inAppNotificationsEnabled: boolean;
|
inAppNotificationsEnabled: boolean;
|
||||||
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -55,7 +52,7 @@ interface UserAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> { }
|
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||||
public userId!: string;
|
public userId!: string;
|
||||||
@ -68,7 +65,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public department?: string;
|
public department?: string;
|
||||||
public designation?: string;
|
public designation?: string;
|
||||||
public phone?: string;
|
public phone?: string;
|
||||||
|
|
||||||
// Extended fields from SSO/Okta (All Optional)
|
// Extended fields from SSO/Okta (All Optional)
|
||||||
public manager?: string | null;
|
public manager?: string | null;
|
||||||
public secondEmail?: string | null;
|
public secondEmail?: string | null;
|
||||||
@ -77,7 +74,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public postalAddress?: string | null;
|
public postalAddress?: string | null;
|
||||||
public mobilePhone?: string | null;
|
public mobilePhone?: string | null;
|
||||||
public adGroups?: string[] | null;
|
public adGroups?: string[] | null;
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
public location?: {
|
public location?: {
|
||||||
city?: string;
|
city?: string;
|
||||||
@ -86,12 +83,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
public emailNotificationsEnabled!: boolean;
|
public emailNotificationsEnabled!: boolean;
|
||||||
public pushNotificationsEnabled!: boolean;
|
public pushNotificationsEnabled!: boolean;
|
||||||
public inAppNotificationsEnabled!: boolean;
|
public inAppNotificationsEnabled!: boolean;
|
||||||
|
|
||||||
public isActive!: boolean;
|
public isActive!: boolean;
|
||||||
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
public lastLogin?: Date;
|
public lastLogin?: Date;
|
||||||
@ -99,26 +96,26 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
public updatedAt!: Date;
|
public updatedAt!: Date;
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper Methods for Role Checking
|
* Helper Methods for Role Checking
|
||||||
*/
|
*/
|
||||||
public isUserRole(): boolean {
|
public isUserRole(): boolean {
|
||||||
return this.role === 'USER';
|
return this.role === 'USER';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isManagementRole(): boolean {
|
public isManagementRole(): boolean {
|
||||||
return this.role === 'MANAGEMENT';
|
return this.role === 'MANAGEMENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
public isAdminRole(): boolean {
|
public isAdminRole(): boolean {
|
||||||
return this.role === 'ADMIN';
|
return this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasManagementAccess(): boolean {
|
public hasManagementAccess(): boolean {
|
||||||
return this.role === 'MANAGEMENT' || this.role === 'ADMIN';
|
return this.role === 'MANAGEMENT' || this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasAdminAccess(): boolean {
|
public hasAdminAccess(): boolean {
|
||||||
return this.role === 'ADMIN';
|
return this.role === 'ADMIN';
|
||||||
}
|
}
|
||||||
@ -184,7 +181,7 @@ User.init(
|
|||||||
type: DataTypes.STRING(20),
|
type: DataTypes.STRING(20),
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Extended SSO/Okta Fields (All Optional) ============
|
// ============ Extended SSO/Okta Fields (All Optional) ============
|
||||||
manager: {
|
manager: {
|
||||||
type: DataTypes.STRING(200),
|
type: DataTypes.STRING(200),
|
||||||
@ -230,14 +227,14 @@ User.init(
|
|||||||
field: 'ad_groups',
|
field: 'ad_groups',
|
||||||
comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array'
|
comment: 'Active Directory group memberships from SSO (memberOf field) - JSON array'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Location Information (JSON object)
|
// Location Information (JSON object)
|
||||||
location: {
|
location: {
|
||||||
type: DataTypes.JSONB, // Use JSONB for PostgreSQL
|
type: DataTypes.JSONB, // Use JSONB for PostgreSQL
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Notification Preferences
|
// Notification Preferences
|
||||||
emailNotificationsEnabled: {
|
emailNotificationsEnabled: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
@ -260,7 +257,7 @@ User.init(
|
|||||||
field: 'in_app_notifications_enabled',
|
field: 'in_app_notifications_enabled',
|
||||||
comment: 'User preference for receiving in-app notifications'
|
comment: 'User preference for receiving in-app notifications'
|
||||||
},
|
},
|
||||||
|
|
||||||
isActive: {
|
isActive: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
|||||||
@ -26,9 +26,6 @@ import { Dealer } from './Dealer';
|
|||||||
import { ActivityType } from './ActivityType';
|
import { ActivityType } from './ActivityType';
|
||||||
import { DealerClaimHistory } from './DealerClaimHistory';
|
import { DealerClaimHistory } from './DealerClaimHistory';
|
||||||
import { WorkflowTemplate } from './WorkflowTemplate';
|
import { WorkflowTemplate } from './WorkflowTemplate';
|
||||||
import { ClaimInvoice } from './ClaimInvoice';
|
|
||||||
import { ClaimInvoiceItem } from './ClaimInvoiceItem';
|
|
||||||
import { ClaimCreditNote } from './ClaimCreditNote';
|
|
||||||
|
|
||||||
// Define associations
|
// Define associations
|
||||||
const defineAssociations = () => {
|
const defineAssociations = () => {
|
||||||
@ -182,10 +179,7 @@ export {
|
|||||||
ClaimBudgetTracking,
|
ClaimBudgetTracking,
|
||||||
Dealer,
|
Dealer,
|
||||||
ActivityType,
|
ActivityType,
|
||||||
DealerClaimHistory,
|
DealerClaimHistory
|
||||||
ClaimInvoice,
|
|
||||||
ClaimInvoiceItem,
|
|
||||||
ClaimCreditNote
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default sequelize instance
|
// Export default sequelize instance
|
||||||
|
|||||||
@ -1,21 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticateToken } from '@middlewares/auth.middleware';
|
import { authenticateToken } from '@middlewares/auth.middleware';
|
||||||
import { requireAdmin } from '@middlewares/authorization.middleware';
|
import { requireAdmin } from '@middlewares/authorization.middleware';
|
||||||
import { validateBody, validateParams } from '../middlewares/validate.middleware';
|
|
||||||
import {
|
|
||||||
createHolidaySchema,
|
|
||||||
updateHolidaySchema,
|
|
||||||
holidayParamsSchema,
|
|
||||||
calendarParamsSchema,
|
|
||||||
configKeyParamsSchema,
|
|
||||||
updateConfigSchema,
|
|
||||||
assignRoleSchema,
|
|
||||||
updateRoleSchema,
|
|
||||||
userIdParamsSchema,
|
|
||||||
createActivityTypeSchema,
|
|
||||||
updateActivityTypeSchema,
|
|
||||||
activityTypeParamsSchema,
|
|
||||||
} from '../validators/admin.validator';
|
|
||||||
import {
|
import {
|
||||||
getAllHolidays,
|
getAllHolidays,
|
||||||
getHolidayCalendar,
|
getHolidayCalendar,
|
||||||
@ -59,7 +44,7 @@ router.get('/holidays', getAllHolidays);
|
|||||||
* @params year
|
* @params year
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), getHolidayCalendar);
|
router.get('/holidays/calendar/:year', getHolidayCalendar);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/holidays
|
* @route POST /api/admin/holidays
|
||||||
@ -67,7 +52,7 @@ router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), get
|
|||||||
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
|
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
|
router.post('/holidays', createHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/holidays/:holidayId
|
* @route PUT /api/admin/holidays/:holidayId
|
||||||
@ -76,7 +61,7 @@ router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
|
|||||||
* @body Holiday fields to update
|
* @body Holiday fields to update
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validateBody(updateHolidaySchema), updateHoliday);
|
router.put('/holidays/:holidayId', updateHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route DELETE /api/admin/holidays/:holidayId
|
* @route DELETE /api/admin/holidays/:holidayId
|
||||||
@ -84,7 +69,7 @@ router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validate
|
|||||||
* @params holidayId
|
* @params holidayId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.delete('/holidays/:holidayId', validateParams(holidayParamsSchema), deleteHoliday);
|
router.delete('/holidays/:holidayId', deleteHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/holidays/bulk-import
|
* @route POST /api/admin/holidays/bulk-import
|
||||||
@ -111,7 +96,7 @@ router.get('/configurations', getAllConfigurations);
|
|||||||
* @body { configValue }
|
* @body { configValue }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/configurations/:configKey', validateParams(configKeyParamsSchema), validateBody(updateConfigSchema), updateConfiguration);
|
router.put('/configurations/:configKey', updateConfiguration);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/configurations/:configKey/reset
|
* @route POST /api/admin/configurations/:configKey/reset
|
||||||
@ -119,7 +104,7 @@ router.put('/configurations/:configKey', validateParams(configKeyParamsSchema),
|
|||||||
* @params configKey
|
* @params configKey
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
|
router.post('/configurations/:configKey/reset', resetConfiguration);
|
||||||
|
|
||||||
// ==================== User Role Management Routes (RBAC) ====================
|
// ==================== User Role Management Routes (RBAC) ====================
|
||||||
|
|
||||||
@ -129,7 +114,7 @@ router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSc
|
|||||||
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEmail);
|
router.post('/users/assign-role', assignRoleByEmail);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/users/:userId/role
|
* @route PUT /api/admin/users/:userId/role
|
||||||
@ -138,7 +123,7 @@ router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEm
|
|||||||
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/users/:userId/role', validateParams(userIdParamsSchema), validateBody(updateRoleSchema), updateUserRole);
|
router.put('/users/:userId/role', updateUserRole);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/admin/users/by-role
|
* @route GET /api/admin/users/by-role
|
||||||
@ -171,7 +156,7 @@ router.get('/activity-types', getAllActivityTypes);
|
|||||||
* @params activityTypeId
|
* @params activityTypeId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), getActivityTypeById);
|
router.get('/activity-types/:activityTypeId', getActivityTypeById);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/activity-types
|
* @route POST /api/admin/activity-types
|
||||||
@ -179,7 +164,7 @@ router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsS
|
|||||||
* @body { title, itemCode?, taxationType?, sapRefNo? }
|
* @body { title, itemCode?, taxationType?, sapRefNo? }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/activity-types', validateBody(createActivityTypeSchema), createActivityType);
|
router.post('/activity-types', createActivityType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/activity-types/:activityTypeId
|
* @route PUT /api/admin/activity-types/:activityTypeId
|
||||||
@ -188,7 +173,7 @@ router.post('/activity-types', validateBody(createActivityTypeSchema), createAct
|
|||||||
* @body Activity type fields to update
|
* @body Activity type fields to update
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), validateBody(updateActivityTypeSchema), updateActivityType);
|
router.put('/activity-types/:activityTypeId', updateActivityType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route DELETE /api/admin/activity-types/:activityTypeId
|
* @route DELETE /api/admin/activity-types/:activityTypeId
|
||||||
@ -196,6 +181,7 @@ router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsS
|
|||||||
* @params activityTypeId
|
* @params activityTypeId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.delete('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), deleteActivityType);
|
router.delete('/activity-types/:activityTypeId', deleteActivityType);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user