vulnnearable comments removed and source exposing to frobrowser disabled worknote XSS fixed

This commit is contained in:
laxmanhalaki 2026-02-13 14:46:08 +05:30
parent b32a3505ac
commit 00e1d51c66
77 changed files with 499 additions and 443 deletions

View File

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

View File

@ -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@{{API_DOMAIN}}'; UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{APP_DOMAIN}}';
[Config Seed] ✅ Default configurations seeded successfully (30 settings) [Config Seed] ✅ Default configurations seeded successfully (30 settings)
info: ✅ Server started successfully on port 5000 info: ✅ Server started successfully on port 5000
@ -112,7 +112,7 @@ psql -d royal_enfield_workflow
UPDATE users UPDATE users
SET role = 'ADMIN' SET role = 'ADMIN'
WHERE email = 'your-email@{{API_DOMAIN}}'; WHERE email = 'your-email@{{APP_DOMAIN}}';
\q \q
``` ```

View File

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

View File

@ -66,7 +66,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n // Okta username (email)\n \"username\": \"user@{{API_DOMAIN}}\",\n \n // Okta password\n \"password\": \"YourOktaPassword123\"\n}" "raw": "{\n // Okta username (email)\n \"username\": \"user@{{APP_DOMAIN}}\",\n \n // Okta password\n \"password\": \"YourOktaPassword123\"\n}"
}, },
"url": { "url": {
"raw": "{{baseUrl}}/auth/login", "raw": "{{baseUrl}}/auth/login",
@ -498,7 +498,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"templateType\": \"CUSTOM\",\n \"title\": \"Purchase Order Approval for Office Equipment\",\n \"description\": \"Approval needed for purchasing new office equipment including laptops, monitors, and office furniture. Total budget: $50,000\",\n \"priority\": \"STANDARD\",\n \"approvalLevels\": [\n {\n \"email\": \"manager@{{API_DOMAIN}}\",\n \"tatHours\": 24\n },\n {\n \"email\": \"director@{{API_DOMAIN}}\",\n \"tatHours\": 48\n },\n {\n \"email\": \"cfo@{{API_DOMAIN}}\",\n \"tatHours\": 72\n }\n ],\n \"spectators\": [\n {\n \"email\": \"hr@{{API_DOMAIN}}\"\n },\n {\n \"email\": \"finance@{{API_DOMAIN}}\"\n }\n ]\n}" "raw": "{\n \"templateType\": \"CUSTOM\",\n \"title\": \"Purchase Order Approval for Office Equipment\",\n \"description\": \"Approval needed for purchasing new office equipment including laptops, monitors, and office furniture. Total budget: $50,000\",\n \"priority\": \"STANDARD\",\n \"approvalLevels\": [\n {\n \"email\": \"manager@{{APP_DOMAIN}}\",\n \"tatHours\": 24\n },\n {\n \"email\": \"director@{{APP_DOMAIN}}\",\n \"tatHours\": 48\n },\n {\n \"email\": \"cfo@{{APP_DOMAIN}}\",\n \"tatHours\": 72\n }\n ],\n \"spectators\": [\n {\n \"email\": \"hr@{{APP_DOMAIN}}\"\n },\n {\n \"email\": \"finance@{{APP_DOMAIN}}\"\n }\n ]\n}"
}, },
"url": { "url": {
"raw": "{{baseUrl}}/workflows", "raw": "{{baseUrl}}/workflows",
@ -522,7 +522,7 @@
"formdata": [ "formdata": [
{ {
"key": "payload", "key": "payload",
"value": "{\"templateType\":\"CUSTOM\",\"title\":\"Purchase Order Approval with Documents\",\"description\":\"Approval needed for office equipment purchase with supporting documents\",\"priority\":\"STANDARD\",\"approvalLevels\":[{\"email\":\"manager@{{API_DOMAIN}}\",\"tatHours\":24},{\"email\":\"director@{{API_DOMAIN}}\",\"tatHours\":48}],\"spectators\":[{\"email\":\"hr@{{API_DOMAIN}}\"}]}", "value": "{\"templateType\":\"CUSTOM\",\"title\":\"Purchase Order Approval with Documents\",\"description\":\"Approval needed for office equipment purchase with supporting documents\",\"priority\":\"STANDARD\",\"approvalLevels\":[{\"email\":\"manager@{{APP_DOMAIN}}\",\"tatHours\":24},{\"email\":\"director@{{APP_DOMAIN}}\",\"tatHours\":48}],\"spectators\":[{\"email\":\"hr@{{APP_DOMAIN}}\"}]}",
"type": "text", "type": "text",
"description": "JSON payload with simplified format (email + tatHours only)" "description": "JSON payload with simplified format (email + tatHours only)"
}, },
@ -719,7 +719,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"email\": \"newapprover@{{API_DOMAIN}}\",\n \"tatHours\": 24,\n \"level\": 2\n}" "raw": "{\n \"email\": \"newapprover@{{APP_DOMAIN}}\",\n \"tatHours\": 24,\n \"level\": 2\n}"
}, },
"url": { "url": {
"raw": "{{baseUrl}}/workflows/:id/approvers/at-level", "raw": "{{baseUrl}}/workflows/:id/approvers/at-level",
@ -755,7 +755,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"email\": \"spectator@{{API_DOMAIN}}\"\n}" "raw": "{\n \"email\": \"spectator@{{APP_DOMAIN}}\"\n}"
}, },
"url": { "url": {
"raw": "{{baseUrl}}/workflows/:id/participants/spectator", "raw": "{{baseUrl}}/workflows/:id/participants/spectator",
@ -3038,4 +3038,4 @@
] ]
} }
] ]
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,14 +10,14 @@
<meta name="theme-color" content="#2d4a3e" /> <meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title> <title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons --> <!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-y_ojbF9T.js"></script> <script type="module" crossorigin src="/assets/index-5rjlVIR5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DfwWW08H.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-BFJfF1vG.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js"> <link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">

4
build/robots.txt Normal file
View File

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

9
build/sitemap.xml Normal file
View File

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

View File

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

View File

@ -112,7 +112,7 @@ Your CSV file must have these **44 columns** in the following order:
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) | | `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) | | `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
| `single_format_month_year` | String(50) | No | Format: Sep-2014 | | `single_format_month_year` | String(50) | No | Format: Sep-2014 |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{API_DOMAIN}}) | | `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
| `replacement` | String(50) | No | Replacement status | | `replacement` | String(50) | No | Replacement status |
| `termination_resignation_status` | String(255) | No | Termination/Resignation status | | `termination_resignation_status` | String(255) | No | Termination/Resignation status |
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD | | `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
```csv ```csv
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{API_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.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
``` ```
**What gets auto-generated:** **What gets auto-generated:**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,8 +72,8 @@ The Users API returns a complete user object:
"employeeID": "E09994", "employeeID": "E09994",
"title": "Supports Business Applications (SAP) portfolio", "title": "Supports Business Applications (SAP) portfolio",
"department": "Deputy Manager - Digital & IT", "department": "Deputy Manager - Digital & IT",
"login": "sanjaysahu@{{API_DOMAIN}}", "login": "sanjaysahu@{{APP_DOMAIN}}",
"email": "sanjaysahu@{{API_DOMAIN}}" "email": "sanjaysahu@{{APP_DOMAIN}}"
}, },
... ...
} }

View File

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

View File

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

View File

@ -650,7 +650,7 @@ graph LR
{ {
"userId": "uuid", "userId": "uuid",
"employeeId": "EMP001", "employeeId": "EMP001",
"email": "user@{{API_DOMAIN}}", "email": "user@{{APP_DOMAIN}}",
"role": "USER" | "MANAGEMENT" | "ADMIN", "role": "USER" | "MANAGEMENT" | "ADMIN",
"iat": 1234567890, "iat": 1234567890,
"exp": 1234654290 "exp": 1234654290

View File

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

View File

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

View File

@ -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@{{API_DOMAIN}} SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your_smtp_password SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
# AI Service (for conclusion generation) - Vertex AI Gemini # AI Service (for conclusion generation) - Vertex AI Gemini
# Uses service account credentials from GCP_KEY_FILE # Uses service account credentials from GCP_KEY_FILE

View File

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

View File

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

View File

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

View File

@ -38,8 +38,10 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
connectSrc.push(...origins); connectSrc.push(...origins);
} }
const apiDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Define strict CSP directives // Define strict CSP directives
// CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance //: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
// even if the header is truncated in certain response types (like 301 redirects). // even if the header is truncated in certain response types (like 301 redirects).
const directives = [ const directives = [
"frame-ancestors 'self'", "frame-ancestors 'self'",
@ -53,7 +55,7 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
"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://*.{{API_DOMAIN}} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com", `img-src 'self' data: blob: https://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`,
"frame-src 'self' blob: data:", "frame-src 'self' blob: data:",
"font-src 'self' https://fonts.gstatic.com data:", "font-src 'self' https://fonts.gstatic.com data:",
"object-src 'none'", "object-src 'none'",

View File

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

View File

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

View File

@ -151,7 +151,7 @@ export class AuthController {
message: 'Token refreshed successfully' message: 'Token refreshed successfully'
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
} else { } else {
// Development: Include token for debugging // Dev: Include token for debugging
ResponseHandler.success(res, { ResponseHandler.success(res, {
accessToken: newAccessToken accessToken: newAccessToken
}, 'Token refreshed successfully'); }, 'Token refreshed successfully');
@ -602,7 +602,7 @@ export class AuthController {
idToken: result.oktaIdToken idToken: result.oktaIdToken
}, 'Token exchange successful'); }, 'Token exchange successful');
} else { } else {
// Development: Include tokens for debugging and different-port setup // Dev: Include tokens for debugging and different-port setup
ResponseHandler.success(res, { ResponseHandler.success(res, {
user: result.user, user: result.user,
accessToken: result.accessToken, accessToken: result.accessToken,

View File

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

View File

@ -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@{{API_DOMAIN}} SMTP_USER=notifications@{{APP_DOMAIN}}
SMTP_PASSWORD=your-app-specific-password SMTP_PASSWORD=your-app-specific-password
EMAIL_FROM=RE Flow <noreply@{{API_DOMAIN}}> EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
# Email Settings # Email Settings
EMAIL_ENABLED=true EMAIL_ENABLED=true
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
EMAIL_RETRY_ATTEMPTS=3 EMAIL_RETRY_ATTEMPTS=3
# Application # Application
BASE_URL=https://workflow.{{API_DOMAIN}} BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
COMPANY_WEBSITE=https://www.{{API_DOMAIN}} COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
SUPPORT_EMAIL=support@{{API_DOMAIN}} SUPPORT_EMAIL=support@{{APP_DOMAIN}}
``` ```
--- ---

View File

@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
All templates feature a single action button: 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.{{API_DOMAIN}}/request/REQ-2025-12-0013` - **Example:** `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
No approval/rejection buttons in emails - all actions happen within the application. No approval/rejection buttons in emails - all actions happen within the application.
@ -231,8 +231,8 @@ SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password SMTP_PASSWORD=your-app-password
# Email Settings # Email Settings
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}> EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
BASE_URL=https://workflow.{{API_DOMAIN}} BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
``` ```

View File

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

View File

@ -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.{{API_DOMAIN}}/request/REQ-2025-12-0013', viewDetailsLink: 'https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013',
companyName: 'Royal Enfield' companyName: 'Royal Enfield'
}; };
``` ```
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password SMTP_PASSWORD=your-app-password
# Email Settings # Email Settings
EMAIL_FROM=Royal Enfield Workflow <notifications@{{API_DOMAIN}}> EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
# Application Settings # Application Settings
BASE_URL=https://workflow.{{API_DOMAIN}} BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield COMPANY_NAME=Royal Enfield
``` ```

View File

@ -7,18 +7,20 @@
import { EmailHeaderConfig, EmailFooterConfig } from './helpers'; import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
/** /**
* Company Information * Company Information
*/ */
export const CompanyInfo = { export const CompanyInfo = {
name: 'Royal Enfield', name: 'Royal Enfield',
productName: 'RE Flow', // Product name displayed in header productName: 'RE Flow', // Product name displayed in header
website: 'https://www.{{API_DOMAIN}}', website: `https://www.${appDomain}`,
supportEmail: 'support@{{API_DOMAIN}}', supportEmail: `support@${appDomain}`,
// Logo configuration for email headers // Logo configuration for email headers
logo: { logo: {
url: 'https://www.{{API_DOMAIN}}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp', url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`,
alt: 'Royal Enfield Logo', alt: 'Royal Enfield Logo',
width: 220, // Logo width in pixels (wider for better visibility) width: 220, // Logo width in pixels (wider for better visibility)
height: 65, // Logo height in pixels (proportional ratio ~3.4:1) height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
@ -88,7 +90,7 @@ export const CustomHeaderStyles = {
* Usage in email service: * Usage in email service:
* const link = getViewDetailsLink('REQ-2025-12-0013', process.env.FRONTEND_URL); * const link = getViewDetailsLink('REQ-2025-12-0013', process.env.FRONTEND_URL);
* *
* Result: https://workflow.{{API_DOMAIN}}/request/REQ-2025-12-0013 * Result: https://workflow.royalenfield.com/request/REQ-2025-12-0013
*/ */
export function getViewDetailsLink(requestNumber: string, frontendUrl: string): string { export function getViewDetailsLink(requestNumber: string, frontendUrl: string): string {
return `${frontendUrl}/request/${requestNumber}`; return `${frontendUrl}/request/${requestNumber}`;

View File

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

View File

@ -14,13 +14,13 @@ async function generatePreviews() {
// Sample data // Sample data
const initiator = { const initiator = {
userId: 'user-1', userId: 'user-1',
email: 'john.doe@{{API_DOMAIN}}', email: 'john.doe@royalenfield.com',
displayName: 'John Doe' displayName: 'John Doe'
}; };
const approver = { const approver = {
userId: 'user-2', userId: 'user-2',
email: 'jane.smith@{{API_DOMAIN}}', email: 'jane.smith@royalenfield.com',
displayName: 'Jane Smith' displayName: 'Jane Smith'
}; };

View File

@ -100,7 +100,7 @@ async function sendTestEmail() {
// Test 1: Request Created Email // Test 1: Request Created Email
const html1 = getRequestCreatedEmail(requestCreatedData); const html1 = getRequestCreatedEmail(requestCreatedData);
const info1 = await transporter.sendMail({ const info1 = await transporter.sendMail({
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>', from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
to: 'initiator@example.com', to: 'initiator@example.com',
subject: '[REQ-2025-12-0013] Request Created Successfully', subject: '[REQ-2025-12-0013] Request Created Successfully',
html: html1 html: html1
@ -113,7 +113,7 @@ async function sendTestEmail() {
// Test 2: Approval Request Email (Single) // Test 2: Approval Request Email (Single)
const html2 = getApprovalRequestEmail(approvalRequestData); const html2 = getApprovalRequestEmail(approvalRequestData);
const info2 = await transporter.sendMail({ const info2 = await transporter.sendMail({
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>', from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
to: 'approver@example.com', to: 'approver@example.com',
subject: '[REQ-2025-12-0013] Approval Request - Action Required', subject: '[REQ-2025-12-0013] Approval Request - Action Required',
html: html2 html: html2
@ -126,7 +126,7 @@ async function sendTestEmail() {
// Test 3: Multi-Approver Request Email // Test 3: Multi-Approver Request Email
const html3 = getMultiApproverRequestEmail(multiApproverData); const html3 = getMultiApproverRequestEmail(multiApproverData);
const info3 = await transporter.sendMail({ const info3 = await transporter.sendMail({
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>', from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
to: 'approver-level2@example.com', to: 'approver-level2@example.com',
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

View File

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

View File

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

View File

@ -7,6 +7,9 @@ import { getPublicConfigurations } from '../controllers/admin.controller';
const router = Router(); const router = Router();
const userController = new UserController(); const userController = new UserController();
// GET /api/v1/users - Get all users
router.get('/', authenticateToken, asyncHandler(userController.getAllUsers.bind(userController)));
// GET /api/v1/users/search?q=<email or name> // GET /api/v1/users/search?q=<email or name>
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController))); router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
@ -19,6 +22,9 @@ router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigura
// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists) // POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists)
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController))); router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));
// GET /api/v1/users/:userId - Get user by ID
router.get('/:userId', authenticateToken, asyncHandler(userController.getUserById.bind(userController)));
export default router; export default router;

View File

@ -298,6 +298,7 @@ async function autoSetup(): Promise<void> {
// Step 0: Initialize secrets // Step 0: Initialize secrets
console.log('🔐 Initializing secrets...'); console.log('🔐 Initializing secrets...');
await initializeGoogleSecretManager(); await initializeGoogleSecretManager();
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Step 1: Check and create database if needed // Step 1: Check and create database if needed
const wasCreated = await checkAndCreateDatabase(); const wasCreated = await checkAndCreateDatabase();
@ -323,7 +324,7 @@ async function autoSetup(): Promise<void> {
console.log(' 1. Server will start automatically'); console.log(' 1. Server will start automatically');
console.log(' 2. Log in via SSO'); console.log(' 2. Log in via SSO');
console.log(' 3. Run this SQL to make yourself admin:'); console.log(' 3. Run this SQL to make yourself admin:');
console.log(` UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{API_DOMAIN}}';\n`); console.log(` UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@${appDomain}';\n`);
} }
} catch (error: any) { } catch (error: any) {

View File

@ -10,6 +10,8 @@ import { Dealer } from '../models/Dealer';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import logger from '../utils/logger'; import logger from '../utils/logger';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
interface DealerSeedData { interface DealerSeedData {
salesCode?: string | null; salesCode?: string | null;
serviceCode?: string | null; serviceCode?: string | null;
@ -77,7 +79,7 @@ const dealersData: DealerSeedData[] = [
onBoardingCharges: null, onBoardingCharges: null,
date: '2014-09-30', date: '2014-09-30',
singleFormatMonthYear: 'Sep-2014', singleFormatMonthYear: 'Sep-2014',
domainId: 'acceleratemotors.rrnagar@dealer.{{API_DOMAIN}}', domainId: `acceleratemotors.rrnagar@dealer.${appDomain}`,
replacement: null, replacement: null,
terminationResignationStatus: null, terminationResignationStatus: null,
dateOfTerminationResignation: null, dateOfTerminationResignation: null,

View File

@ -8,6 +8,8 @@ import { sequelize } from '../config/database';
import { User } from '../models/User'; import { User } from '../models/User';
import logger from '../utils/logger'; import logger from '../utils/logger';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
interface DealerData { interface DealerData {
email: string; email: string;
dealerCode: string; dealerCode: string;
@ -21,7 +23,7 @@ interface DealerData {
const dealers: DealerData[] = [ const dealers: DealerData[] = [
{ {
email: 'test.2@{{API_DOMAIN}}', email: `test.2@${appDomain}`,
dealerCode: 'RE-MH-001', dealerCode: 'RE-MH-001',
dealerName: 'Royal Motors Mumbai', dealerName: 'Royal Motors Mumbai',
displayName: 'Royal Motors Mumbai', displayName: 'Royal Motors Mumbai',
@ -31,7 +33,7 @@ const dealers: DealerData[] = [
role: 'USER', role: 'USER',
}, },
{ {
email: 'test.4@{{API_DOMAIN}}', email: `test.4@${appDomain}`,
dealerCode: 'RE-DL-002', dealerCode: 'RE-DL-002',
dealerName: 'Delhi enfield center', dealerName: 'Delhi enfield center',
displayName: 'Delhi Enfield Center', displayName: 'Delhi Enfield Center',

View File

@ -12,6 +12,8 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service'; import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service'; import { tatSchedulerService } from './tatScheduler.service';
import { emitToRequestRoom } from '../realtime/socket'; import { emitToRequestRoom } from '../realtime/socket';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService // Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
export class ApprovalService { export class ApprovalService {
@ -538,19 +540,19 @@ export class ApprovalService {
// Check if it's an auto-step by checking approverEmail or levelName // Check if it's an auto-step by checking approverEmail or levelName
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps // Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
// These steps are processed automatically and should NOT trigger notifications // These steps are processed automatically and should NOT trigger notifications
const isAutoStep = (nextLevel as any).approverEmail === 'system@{{API_DOMAIN}}' const isAutoStep = (nextLevel as any).approverEmail === `system@${appDomain}`
|| (nextLevel as any).approverName === 'System Auto-Process' || (nextLevel as any).approverName === 'System Auto-Process'
|| (nextLevel as any).approverId === 'system'; || (nextLevel as any).approverId === 'system';
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps // IMPORTANT: Skip notifications and assignment logging for system/auto-steps
// System steps are any step with system@{{API_DOMAIN}} // System steps are any step with system@${appDomain}
// Only send notifications to real users, NOT system processes // Only send notifications to real users, NOT system processes
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') { if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
// Additional checks: ensure approverEmail and approverName are not system-related // Additional checks: ensure approverEmail and approverName are not system-related
// This prevents notifications to system accounts even if they pass other checks // This prevents notifications to system accounts even if they pass other checks
const approverEmail = (nextLevel as any).approverEmail || ''; const approverEmail = (nextLevel as any).approverEmail || '';
const approverName = (nextLevel as any).approverName || ''; const approverName = (nextLevel as any).approverName || '';
const isSystemEmail = approverEmail.toLowerCase() === 'system@{{API_DOMAIN}}' const isSystemEmail = approverEmail.toLowerCase() === `system@${appDomain}`
|| approverEmail.toLowerCase().includes('system'); || approverEmail.toLowerCase().includes('system');
const isSystemName = approverName.toLowerCase() === 'system auto-process' const isSystemName = approverName.toLowerCase() === 'system auto-process'
|| approverName.toLowerCase().includes('system'); || approverName.toLowerCase().includes('system');

View File

@ -26,7 +26,7 @@ import { dmsIntegrationService } from './dmsIntegration.service';
import { findDealerLocally } from './dealer.service'; import { findDealerLocally } from './dealer.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
let workflowServiceInstance: any; let workflowServiceInstance: any;
@ -171,7 +171,7 @@ export class DealerClaimService {
levelNumber: a.level, levelNumber: a.level,
levelName: levelName, levelName: levelName,
approverId: approverUserId || '', // Fallback to empty string if still not resolved approverId: approverUserId || '', // Fallback to empty string if still not resolved
approverEmail: a.email, approverEmail: `system@${appDomain}`,
approverName: a.name || a.email, approverName: a.name || a.email,
tatHours: tatHours, tatHours: tatHours,
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval) // New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
@ -330,7 +330,9 @@ export class DealerClaimService {
let stepDef = null; let stepDef = null;
// Check if this is a system step by email (for backwards compatibility) // Check if this is a system step by email (for backwards compatibility)
const isSystemEmail = approver.email === 'system@{{API_DOMAIN}}' || approver.email === 'finance@{{API_DOMAIN}}'; const systemEmails = [`system@${appDomain}`];
const financeEmails = [`finance@${appDomain}`];
const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email);
if (approver.isAdditional) { if (approver.isAdditional) {
// Additional approver - use stepName from frontend // Additional approver - use stepName from frontend
@ -594,7 +596,7 @@ export class DealerClaimService {
}); });
// 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed) // 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed)
if (dealerEmail && dealerEmail.toLowerCase() !== 'system@{{API_DOMAIN}}') { if (dealerEmail && dealerEmail.toLowerCase() !== `system@${appDomain}`) {
let dealerUser = await User.findOne({ let dealerUser = await User.findOne({
where: { email: dealerEmail.toLowerCase() }, where: { email: dealerEmail.toLowerCase() },
}); });
@ -626,7 +628,7 @@ export class DealerClaimService {
// 3. Add all approvers from approval levels (excluding system and duplicates) // 3. Add all approvers from approval levels (excluding system and duplicates)
const addedUserIds = new Set<string>([initiatorId]); const addedUserIds = new Set<string>([initiatorId]);
const systemEmails = ['system@{{API_DOMAIN}}']; const systemEmails = [`system@${appDomain}`];
for (const level of approvalLevels) { for (const level of approvalLevels) {
const approverEmail = (level as any).approverEmail?.toLowerCase(); const approverEmail = (level as any).approverEmail?.toLowerCase();
@ -3163,7 +3165,7 @@ export class DealerClaimService {
deptLeadLevel.levelName || undefined deptLeadLevel.levelName || undefined
); );
} catch (snapshotError) { } catch (snapshotError) {
// Log error but don't fail the reopen - snapshot is for audit, not critical // Log error but don't fail the reopen - snapshot is for audit, not essential
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError); logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
} }
@ -3286,7 +3288,7 @@ export class DealerClaimService {
previousLevel.levelName || undefined previousLevel.levelName || undefined
); );
} catch (snapshotError) { } catch (snapshotError) {
// Log error but don't fail the revise - snapshot is for audit, not critical // Log error but don't fail the revise - snapshot is for audit, not essential
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError); logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
} }

View File

@ -25,6 +25,8 @@ import { tatSchedulerService } from './tatScheduler.service';
import { DealerClaimService } from './dealerClaim.service'; import { DealerClaimService } from './dealerClaim.service';
import { emitToRequestRoom } from '../realtime/socket'; import { emitToRequestRoom } from '../realtime/socket';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
let dealerClaimServiceInstance: any; let dealerClaimServiceInstance: any;
@ -147,7 +149,7 @@ export class DealerClaimApprovalService {
userId userId
); );
} catch (snapshotError) { } catch (snapshotError) {
// Log error but don't fail the approval - snapshot is for audit, not critical // Log error but don't fail the approval - snapshot is for audit, not essential
logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError); logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError);
} }
} }
@ -399,11 +401,11 @@ export class DealerClaimApprovalService {
const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver'; const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver';
// Check if it's an auto-step or system process // Check if it's an auto-step or system process
const isAutoStep = nextApproverEmail === 'system@{{API_DOMAIN}}' const isAutoStep = nextApproverEmail === `system@${appDomain}`
|| (nextLevel as any).approverName === 'System Auto-Process' || (nextLevel as any).approverName === 'System Auto-Process'
|| nextApproverId === 'system'; || nextApproverId === 'system';
const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@{{API_DOMAIN}}' const isSystemEmail = nextApproverEmail.toLowerCase() === `system@${appDomain}`
|| nextApproverEmail.toLowerCase().includes('system'); || nextApproverEmail.toLowerCase().includes('system');
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process' const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|| nextApproverName.toLowerCase().includes('system'); || nextApproverName.toLowerCase().includes('system');
@ -747,7 +749,7 @@ export class DealerClaimApprovalService {
level.levelName || undefined level.levelName || undefined
); );
} catch (snapshotError) { } catch (snapshotError) {
// Log error but don't fail the rejection - snapshot is for audit, not critical // Log error but don't fail the rejection - snapshot is for audit, not essential
logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError); logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError);
} }
@ -962,4 +964,3 @@ export class DealerClaimApprovalService {
}); });
} }
} }

View File

@ -17,11 +17,7 @@ interface EmailOptions {
attachments?: any[]; attachments?: any[];
} }
// Hardcoded BCC addresses (temporary - for time being)
const HARDCODED_BCC: string[] = [
'{{USER_EMAIL}}',
// Add your BCC email addresses here
];
export class EmailService { export class EmailService {
private transporter: nodemailer.Transporter | null = null; private transporter: nodemailer.Transporter | null = null;
@ -44,7 +40,7 @@ export class EmailService {
return; return;
} }
// Production SMTP configuration // Prod SMTP configuration
try { try {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: smtpHost, host: smtpHost,
@ -119,24 +115,13 @@ export class EmailService {
} }
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to; const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
const fromAddress = process.env.EMAIL_FROM || 'RE Flow <noreply@{{API_DOMAIN}}>'; const fromAddress = process.env.EMAIL_FROM || `RE Flow <noreply@${process.env.APP_DOMAIN || 'royalenfield.com'}>`;
// Merge hardcoded BCC with provided BCC
let bccRecipients: string[] = [];
if (HARDCODED_BCC.length > 0) {
bccRecipients = [...HARDCODED_BCC];
}
if (options.bcc) {
const providedBcc = Array.isArray(options.bcc) ? options.bcc : [options.bcc];
bccRecipients = [...bccRecipients, ...providedBcc];
}
const finalBcc = bccRecipients.length > 0 ? bccRecipients : undefined;
const mailOptions = { const mailOptions = {
from: fromAddress, from: fromAddress,
to: recipients, to: recipients,
cc: options.cc, cc: options.cc,
bcc: finalBcc, bcc: options.bcc || [],
subject: options.subject, subject: options.subject,
html: options.html, html: options.html,
attachments: options.attachments attachments: options.attachments

View File

@ -289,7 +289,7 @@ export class EmailNotificationService {
} }
/** /**
* 4. Send Rejection Notification Email (CRITICAL) * 4. Send Rejection Notification Email (ESSENTIAL)
*/ */
async sendRejectionNotification( async sendRejectionNotification(
requestData: any, requestData: any,
@ -298,7 +298,7 @@ export class EmailNotificationService {
rejectionReason: string rejectionReason: string
): Promise<void> { ): Promise<void> {
try { try {
// Use override for critical emails // Use override for high-priority emails
const canSend = await shouldSendEmailWithOverride( const canSend = await shouldSendEmailWithOverride(
initiatorData.userId, initiatorData.userId,
EmailNotificationType.REQUEST_REJECTED EmailNotificationType.REQUEST_REJECTED
@ -416,7 +416,7 @@ export class EmailNotificationService {
} }
/** /**
* 6. Send TAT Breached Email (CRITICAL) * 6. Send TAT Breached Email (ESSENTIAL)
*/ */
async sendTATBreached( async sendTATBreached(
requestData: any, requestData: any,
@ -428,7 +428,7 @@ export class EmailNotificationService {
} }
): Promise<void> { ): Promise<void> {
try { try {
// Use override for critical emails // Use override for high-priority emails
const canSend = await shouldSendEmailWithOverride( const canSend = await shouldSendEmailWithOverride(
approverData.userId, approverData.userId,
EmailNotificationType.TAT_BREACHED EmailNotificationType.TAT_BREACHED
@ -1086,9 +1086,9 @@ export class EmailNotificationService {
} }
// Check if next approver is the recipient (initiator reviewing their own request) // Check if next approver is the recipient (initiator reviewing their own request)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator || const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId); (nextApproverData && nextApproverData.userId === recipientData.userId);
const data: DealerProposalSubmittedData = { const data: DealerProposalSubmittedData = {
recipientName: recipientData.displayName || recipientData.email, recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber, requestId: requestData.requestNumber,
@ -1102,7 +1102,7 @@ export class EmailNotificationService {
costBreakupSummary: costBreakupSummary, costBreakupSummary: costBreakupSummary,
submittedDate: this.formatDate(proposalData.submittedAt || new Date()), submittedDate: this.formatDate(proposalData.submittedAt || new Date()),
submittedTime: this.formatTime(proposalData.submittedAt || new Date()), submittedTime: this.formatTime(proposalData.submittedAt || new Date()),
nextApproverName: isNextApproverInitiator nextApproverName: isNextApproverInitiator
? undefined // Don't show next approver name if it's the recipient themselves ? undefined // Don't show next approver name if it's the recipient themselves
: (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)), : (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
@ -1218,9 +1218,9 @@ export class EmailNotificationService {
} }
// Check if next approver is the recipient (initiator reviewing their own request) // Check if next approver is the recipient (initiator reviewing their own request)
const isNextApproverInitiator = completionData.nextApproverIsInitiator || const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId); (nextApproverData && nextApproverData.userId === recipientData.userId);
const data: CompletionDocumentsSubmittedData = { const data: CompletionDocumentsSubmittedData = {
recipientName: recipientData.displayName || recipientData.email, recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber, requestId: requestData.requestNumber,
@ -1234,7 +1234,7 @@ export class EmailNotificationService {
documentsCount: completionData.documentsCount, documentsCount: completionData.documentsCount,
submittedDate: this.formatDate(completionData.submittedAt || new Date()), submittedDate: this.formatDate(completionData.submittedAt || new Date()),
submittedTime: this.formatTime(completionData.submittedAt || new Date()), submittedTime: this.formatTime(completionData.submittedAt || new Date()),
nextApproverName: isNextApproverInitiator nextApproverName: isNextApproverInitiator
? undefined // Don't show next approver name if it's the recipient themselves ? undefined // Don't show next approver name if it's the recipient themselves
: (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)), : (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),

View File

@ -23,13 +23,15 @@ interface NotificationPayload {
metadata?: any; metadata?: any;
} }
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
class NotificationService { class NotificationService {
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map(); private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) { configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || ''; const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || '';
const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || ''; const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || '';
const contact = mailto || process.env.VAPID_CONTACT || 'mailto:admin@example.com'; const contact = mailto || process.env.VAPID_CONTACT || `mailto:admin@${appDomain}`;
if (!pub || !priv) { if (!pub || !priv) {
logger.warn('VAPID keys are not configured. Push notifications are disabled.'); logger.warn('VAPID keys are not configured. Push notifications are disabled.');
return; return;
@ -319,12 +321,12 @@ class NotificationService {
} }
// Check if email should be sent (admin + user preferences) // Check if email should be sent (admin + user preferences)
// Critical emails: rejection, tat_breach, breach // emails: rejection, tat_breach, breach
const isCriticalEmail = payload.type === 'rejection' || const isCriticalEmail = payload.type === 'rejection' ||
payload.type === 'tat_breach' || payload.type === 'tat_breach' ||
payload.type === 'breach'; payload.type === 'breach';
const shouldSend = isCriticalEmail const shouldSend = isCriticalEmail
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails ? await shouldSendEmailWithOverride(userId, emailType) // emails
: payload.type === 'assignment' : payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery ? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails : await shouldSendEmail(userId, emailType); // Regular emails
@ -569,7 +571,7 @@ class NotificationService {
approverData = { approverData = {
userId: (rejectedLevel as any).approverId, userId: (rejectedLevel as any).approverId,
displayName: (rejectedLevel as any).approverName || 'Unknown Approver', displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
email: (rejectedLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}', email: (rejectedLevel as any).approverEmail || `unknown@${appDomain}`,
rejectedAt: (rejectedLevel as any).actionDate, rejectedAt: (rejectedLevel as any).actionDate,
comments: (rejectedLevel as any).comments comments: (rejectedLevel as any).comments
}; };
@ -612,7 +614,7 @@ class NotificationService {
approverData = { approverData = {
userId: (currentLevel as any).approverId, userId: (currentLevel as any).approverId,
displayName: (currentLevel as any).approverName || 'Unknown Approver', displayName: (currentLevel as any).approverName || 'Unknown Approver',
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}' email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
}; };
} }
} }
@ -699,7 +701,7 @@ class NotificationService {
approverData = { approverData = {
userId: (currentLevel as any).approverId, userId: (currentLevel as any).approverId,
displayName: (currentLevel as any).approverName || 'Unknown Approver', displayName: (currentLevel as any).approverName || 'Unknown Approver',
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}' email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
}; };
} }
} }
@ -833,7 +835,7 @@ class NotificationService {
recipientData = { recipientData = {
userId: (pausedLevel as any).approverId, userId: (pausedLevel as any).approverId,
displayName: (pausedLevel as any).approverName || 'Unknown Approver', displayName: (pausedLevel as any).approverName || 'Unknown Approver',
email: (pausedLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}' email: (pausedLevel as any).approverEmail || `unknown@${appDomain}`
}; };
} }
} }
@ -885,7 +887,7 @@ class NotificationService {
recipientData = { recipientData = {
userId: (currentLevel as any).approverId, userId: (currentLevel as any).approverId,
displayName: (currentLevel as any).approverName || 'Unknown User', displayName: (currentLevel as any).approverName || 'Unknown User',
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}' email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
}; };
} }
} else { } else {

View File

@ -71,7 +71,7 @@ export class PdfService {
private getInvoiceHtmlTemplate(data: any): string { private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails } = data; const { request, invoice, dealer, claimDetails } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : ''; const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const logoUrl = '{{LOGO_URL}}'; const logoUrl = `{{LOGO_URL}}`;
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
@ -119,7 +119,7 @@ export class PdfService {
<div class="info-grid"> <div class="info-grid">
<div class="info-section"> <div class="info-section">
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div> <div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">{{BUYER_GSTIN}}</div></div> <div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">` + `{{BUYER_GSTIN}}` + `</div></div>
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div> <div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
<br/> <br/>
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div> <div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>

View File

@ -12,6 +12,8 @@ import { DealerClaimDetails } from '../models/DealerClaimDetails';
* PWC E-Invoice Integration Service * PWC E-Invoice Integration Service
* Handles communication with PWC API for signed invoice generation * Handles communication with PWC API for signed invoice generation
*/ */
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
export class PWCIntegrationService { export class PWCIntegrationService {
private apiUrl: string; private apiUrl: string;
private customerId: string; private customerId: string;
@ -123,7 +125,7 @@ export class PWCIntegrationService {
SourceSystem: "RE_WORKFLOW", SourceSystem: "RE_WORKFLOW",
is_irn: "Y", is_irn: "Y",
is_ewb: "N", is_ewb: "N",
email: (request as any).initiator?.email || "system@{{API_DOMAIN}}", email: (request as any).initiator?.email || `system@${appDomain}`,
TranDtls: { TranDtls: {
TaxSch: "GST", TaxSch: "GST",
SubType: "SUPPLY", SubType: "SUPPLY",

View File

@ -34,17 +34,17 @@ export class TatSchedulerService {
// Handle both enum and string (case-insensitive) priority values // Handle both enum and string (case-insensitive) priority values
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority; const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS'; const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
// Get current thresholds from database configuration // Get current thresholds from database configuration
const thresholds = await getTatThresholds(); const thresholds = await getTatThresholds();
// Calculate milestone times using configured thresholds // Calculate milestone times using configured thresholds
// EXPRESS mode: 24/7 calculation (includes holidays, weekends, non-working hours) // EXPRESS mode: 24/7 calculation (includes holidays, weekends, non-working hours)
// STANDARD mode: Working hours only (excludes holidays, weekends, non-working hours) // STANDARD mode: Working hours only (excludes holidays, weekends, non-working hours)
let threshold1Time: Date; let threshold1Time: Date;
let threshold2Time: Date; let threshold2Time: Date;
let breachTime: Date; let breachTime: Date;
if (isExpress) { if (isExpress) {
// EXPRESS: All calendar days (Mon-Sun, including weekends/holidays) but working hours only (9 AM - 6 PM) // EXPRESS: All calendar days (Mon-Sun, including weekends/holidays) but working hours only (9 AM - 6 PM)
const t1 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.first / 100)); const t1 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.first / 100));
@ -89,11 +89,11 @@ export class TatSchedulerService {
// Check if test mode enabled (1 hour = 1 minute) // Check if test mode enabled (1 hour = 1 minute)
const isTestMode = process.env.TAT_TEST_MODE === 'true'; const isTestMode = process.env.TAT_TEST_MODE === 'true';
// Check if times collide (working hours calculation issue) // Check if times collide (working hours calculation issue)
const uniqueTimes = new Set(jobs.map(j => j.targetTime.getTime())); const uniqueTimes = new Set(jobs.map(j => j.targetTime.getTime()));
const hasCollision = uniqueTimes.size < jobs.length; const hasCollision = uniqueTimes.size < jobs.length;
let jobIndex = 0; let jobIndex = 0;
for (const job of jobs) { for (const job of jobs) {
if (job.delay < 0) { if (job.delay < 0) {
@ -102,21 +102,21 @@ export class TatSchedulerService {
} }
let spacedDelay: number; let spacedDelay: number;
if (isTestMode) { if (isTestMode) {
// Test mode: times are already in minutes (tatTimeUtils converts hours to minutes) // Test mode: times are already in minutes (tatTimeUtils converts hours to minutes)
// Just ensure they have minimum spacing for BullMQ reliability // Just ensure they have minimum spacing for BullMQ reliability
spacedDelay = Math.max(job.delay, 5000) + (jobIndex * 5000); spacedDelay = Math.max(job.delay, 5000) + (jobIndex * 5000);
} else if (hasCollision) { } else if (hasCollision) {
// Production with collision: add 5-minute spacing // Prod with collision: add 5-minute spacing
spacedDelay = job.delay + (jobIndex * 300000); spacedDelay = job.delay + (jobIndex * 300000);
} else { } else {
// Production without collision: use calculated delays // Prod without collision: use calculated delays
spacedDelay = job.delay; spacedDelay = job.delay;
} }
const jobId = `tat-${job.type}-${requestId}-${levelId}`; const jobId = `tat-${job.type}-${requestId}-${levelId}`;
await tatQueue.add( await tatQueue.add(
job.type, job.type,
{ {
@ -186,17 +186,17 @@ export class TatSchedulerService {
// Handle both enum and string (case-insensitive) priority values // Handle both enum and string (case-insensitive) priority values
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority; const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS'; const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
// Get current thresholds from database configuration // Get current thresholds from database configuration
const thresholds = await getTatThresholds(); const thresholds = await getTatThresholds();
// Calculate original TAT from remaining + elapsed // Calculate original TAT from remaining + elapsed
// Example: If 35 min used (58.33%) and 25 min remaining, original TAT = 60 min // Example: If 35 min used (58.33%) and 25 min remaining, original TAT = 60 min
const elapsedHours = alertStatus.percentageUsedAtPause > 0 const elapsedHours = alertStatus.percentageUsedAtPause > 0
? (remainingTatHours * alertStatus.percentageUsedAtPause) / (100 - alertStatus.percentageUsedAtPause) ? (remainingTatHours * alertStatus.percentageUsedAtPause) / (100 - alertStatus.percentageUsedAtPause)
: 0; : 0;
const originalTatHours = elapsedHours + remainingTatHours; const originalTatHours = elapsedHours + remainingTatHours;
logger.info(`[TAT Scheduler] Resuming TAT scheduling - Request: ${requestId}, Remaining: ${(remainingTatHours * 60).toFixed(1)} min, Priority: ${isExpress ? 'EXPRESS' : 'STANDARD'}`); logger.info(`[TAT Scheduler] Resuming TAT scheduling - Request: ${requestId}, Remaining: ${(remainingTatHours * 60).toFixed(1)} min, Priority: ${isExpress ? 'EXPRESS' : 'STANDARD'}`);
// Jobs to schedule - only include those that haven't been sent and haven't been passed // Jobs to schedule - only include those that haven't been sent and haven't been passed
@ -216,7 +216,7 @@ export class TatSchedulerService {
// thresholdHours = originalTatHours * (threshold/100) // thresholdHours = originalTatHours * (threshold/100)
const thresholdHours = originalTatHours * (thresholds.first / 100); const thresholdHours = originalTatHours * (thresholds.first / 100);
const hoursFromNow = thresholdHours - elapsedHours; const hoursFromNow = thresholdHours - elapsedHours;
if (hoursFromNow > 0) { if (hoursFromNow > 0) {
jobsToSchedule.push({ jobsToSchedule.push({
type: 'threshold1', type: 'threshold1',
@ -232,7 +232,7 @@ export class TatSchedulerService {
if (!alertStatus.tat75AlertSent && alertStatus.percentageUsedAtPause < thresholds.second) { if (!alertStatus.tat75AlertSent && alertStatus.percentageUsedAtPause < thresholds.second) {
const thresholdHours = originalTatHours * (thresholds.second / 100); const thresholdHours = originalTatHours * (thresholds.second / 100);
const hoursFromNow = thresholdHours - elapsedHours; const hoursFromNow = thresholdHours - elapsedHours;
if (hoursFromNow > 0) { if (hoursFromNow > 0) {
jobsToSchedule.push({ jobsToSchedule.push({
type: 'threshold2', type: 'threshold2',
@ -264,7 +264,7 @@ export class TatSchedulerService {
// Calculate actual times and schedule jobs // Calculate actual times and schedule jobs
for (const job of jobsToSchedule) { for (const job of jobsToSchedule) {
let targetTime: Date; let targetTime: Date;
if (isExpress) { if (isExpress) {
targetTime = (await addWorkingHoursExpress(now, job.hoursFromNow)).toDate(); targetTime = (await addWorkingHoursExpress(now, job.hoursFromNow)).toDate();
} else { } else {
@ -272,14 +272,14 @@ export class TatSchedulerService {
} }
const delay = calculateDelay(targetTime); const delay = calculateDelay(targetTime);
if (delay < 0) { if (delay < 0) {
logger.warn(`[TAT Scheduler] Skipping ${job.type} - calculated time is in past`); logger.warn(`[TAT Scheduler] Skipping ${job.type} - calculated time is in past`);
continue; continue;
} }
const jobId = `tat-${job.type}-${requestId}-${levelId}`; const jobId = `tat-${job.type}-${requestId}-${levelId}`;
await tatQueue.add( await tatQueue.add(
job.type, job.type,
{ {

View File

@ -239,7 +239,7 @@ export class TemplateService {
}); });
} catch (error) { } catch (error) {
logger.error('[TemplateService] Error incrementing usage count:', error); logger.error('[TemplateService] Error incrementing usage count:', error);
// Don't throw - this is not critical // Don't throw - this is not essential
} }
} }
} }

View File

@ -28,7 +28,7 @@ interface OktaUser {
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null { function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
try { try {
const profile = oktaUserResponse.profile || {}; const profile = oktaUserResponse.profile || {};
const userData: SSOUserData = { const userData: SSOUserData = {
oktaSub: oktaUserResponse.id || '', oktaSub: oktaUserResponse.id || '',
email: profile.email || profile.login || '', email: profile.email || profile.login || '',
@ -93,7 +93,7 @@ export class UserService {
return payload; return payload;
} }
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> { async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
// Validate required fields // Validate required fields
if (!ssoData.email || !ssoData.oktaSub) { if (!ssoData.email || !ssoData.oktaSub) {
@ -113,14 +113,14 @@ export class UserService {
if (existingUser) { if (existingUser) {
// Update existing user - DO NOT update email (crucial identifier) // Update existing user - DO NOT update email (crucial identifier)
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
await existingUser.update(updatePayload); await existingUser.update(updatePayload);
return existingUser; return existingUser;
} else { } else {
// Create new user - oktaSub is required, email is included // Create new user - oktaSub is required, email is included
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
const newUser = await UserModel.create(createPayload); const newUser = await UserModel.create(createPayload);
return newUser; return newUser;
@ -141,7 +141,7 @@ export class UserService {
}); });
} }
async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise<any[]> { async searchUsers(query: string, limit: number = 10, excludeUserId?: string, source: 'local' | 'okta' | 'default' = 'default'): Promise<any[]> {
const q = (query || '').trim(); const q = (query || '').trim();
if (!q) { if (!q) {
return []; return [];
@ -160,6 +160,11 @@ export class UserService {
} }
} }
// If source is strictly 'local', skip Okta and search DB directly
if (source === 'local') {
return await this.searchUsersLocal(q, limit, excludeUserId);
}
// Search Okta users // Search Okta users
try { try {
const oktaDomain = process.env.OKTA_DOMAIN; const oktaDomain = process.env.OKTA_DOMAIN;
@ -179,20 +184,20 @@ export class UserService {
}); });
const oktaUsers: OktaUser[] = response.data || []; const oktaUsers: OktaUser[] = response.data || [];
// Transform Okta users to our format // Transform Okta users to our format
return oktaUsers return oktaUsers
.filter(u => { .filter(u => {
// Filter out inactive users // Filter out inactive users
if (u.status !== 'ACTIVE') return false; if (u.status !== 'ACTIVE') return false;
// Filter out current user by Okta ID or email // Filter out current user by Okta ID or email
if (excludeUserId && u.id === excludeUserId) return false; if (excludeUserId && u.id === excludeUserId) return false;
if (excludeEmail) { if (excludeEmail) {
const userEmail = (u.profile.email || u.profile.login || '').toLowerCase(); const userEmail = (u.profile.email || u.profile.login || '').toLowerCase();
if (userEmail === excludeEmail) return false; if (userEmail === excludeEmail) return false;
} }
return true; return true;
}) })
.map(u => ({ .map(u => ({
@ -339,9 +344,9 @@ export class UserService {
// Search Okta users by displayName // Search Okta users by displayName
const response = await axios.get(`${oktaDomain}/api/v1/users`, { const response = await axios.get(`${oktaDomain}/api/v1/users`, {
params: { params: {
search: `profile.displayName eq "${displayName}"`, search: `profile.displayName eq "${displayName}"`,
limit: 50 limit: 50
}, },
headers: { headers: {
'Authorization': `SSWS ${oktaApiToken}`, 'Authorization': `SSWS ${oktaApiToken}`,
@ -351,7 +356,7 @@ export class UserService {
}); });
const oktaUsers: OktaUser[] = response.data || []; const oktaUsers: OktaUser[] = response.data || [];
// Filter only active users // Filter only active users
return oktaUsers.filter(u => u.status === 'ACTIVE'); return oktaUsers.filter(u => u.status === 'ACTIVE');
} catch (error: any) { } catch (error: any) {
@ -367,7 +372,7 @@ export class UserService {
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> { async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
const userData = await this.fetchAndExtractOktaUserByEmail(email); const userData = await this.fetchAndExtractOktaUserByEmail(email);
if (!userData) return null; if (!userData) return null;
// Return in legacy format for backward compatibility // Return in legacy format for backward compatibility
return { return {
id: userData.oktaSub, id: userData.oktaSub,
@ -408,7 +413,7 @@ export class UserService {
location?: string; location?: string;
}): Promise<UserModel> { }): Promise<UserModel> {
const email = oktaUserData.email.toLowerCase(); const email = oktaUserData.email.toLowerCase();
// Check if user already exists in database // Check if user already exists in database
let user = await UserModel.findOne({ let user = await UserModel.findOne({
where: { where: {
@ -426,7 +431,7 @@ export class UserService {
isActive: true, isActive: true,
updatedAt: new Date() updatedAt: new Date()
}; };
if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId; if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId;
if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName; if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName;
if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName; if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName;
@ -435,7 +440,7 @@ export class UserService {
if (oktaUserData.phone) updateData.phone = oktaUserData.phone; if (oktaUserData.phone) updateData.phone = oktaUserData.phone;
if (oktaUserData.designation) updateData.designation = oktaUserData.designation; if (oktaUserData.designation) updateData.designation = oktaUserData.designation;
if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId; if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId;
await user.update(updateData); await user.update(updateData);
return user; return user;
} }

View File

@ -118,7 +118,7 @@ export class WorkflowService {
requestId, requestId,
requestNumber, requestNumber,
url: `/request/${requestNumber}`, url: `/request/${requestNumber}`,
type: 'assignment', // CRITICAL: Differentiates from 'spectator_added' - triggers approval request email type: 'assignment', //: Differentiates from 'spectator_added' - triggers approval request email
priority: 'HIGH', priority: 'HIGH',
actionRequired: true // Approvers need to take action actionRequired: true // Approvers need to take action
}); });
@ -506,7 +506,7 @@ export class WorkflowService {
requestId, requestId,
requestNumber: (workflow as any).requestNumber, requestNumber: (workflow as any).requestNumber,
url: `/request/${(workflow as any).requestNumber}`, url: `/request/${(workflow as any).requestNumber}`,
type: 'assignment', // CRITICAL: This triggers the approval request email notification type: 'assignment', //: This triggers the approval request email notification
priority: 'HIGH', priority: 'HIGH',
actionRequired: true // Additional approvers need to take action actionRequired: true // Additional approvers need to take action
}); });
@ -597,7 +597,7 @@ export class WorkflowService {
requestId, requestId,
requestNumber, requestNumber,
url: `/request/${requestNumber}`, url: `/request/${requestNumber}`,
type: 'spectator_added', // CRITICAL: Differentiates from 'assignment' - triggers spectator added email type: 'spectator_added', //: Differentiates from 'assignment' - triggers spectator added email
priority: 'MEDIUM', // Lower priority than approvers (no action required) priority: 'MEDIUM', // Lower priority than approvers (no action required)
metadata: { metadata: {
addedBy: addedBy // Used in email to show who added the spectator addedBy: addedBy // Used in email to show who added the spectator
@ -3016,7 +3016,7 @@ export class WorkflowService {
if (submissionDate && totalTatHours > 0) { if (submissionDate && totalTatHours > 0) {
// Calculate total elapsed hours by summing elapsed hours from all levels // Calculate total elapsed hours by summing elapsed hours from all levels
// CRITICAL: Only count elapsed hours from completed levels + current active level //: Only count elapsed hours from completed levels + current active level
// Waiting levels (future steps) should contribute 0 elapsed hours // Waiting levels (future steps) should contribute 0 elapsed hours
// This ensures that when in step 1, only step 1's elapsed hours are counted // This ensures that when in step 1, only step 1's elapsed hours are counted
let totalElapsedHours = 0; let totalElapsedHours = 0;
@ -3033,7 +3033,7 @@ export class WorkflowService {
// Skipped levels don't contribute to elapsed time // Skipped levels don't contribute to elapsed time
continue; continue;
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') { } else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
// CRITICAL: Only count elapsed hours for the CURRENT active level //: Only count elapsed hours for the CURRENT active level
// Waiting levels (future steps) should NOT contribute elapsed hours // Waiting levels (future steps) should NOT contribute elapsed hours
// This ensures request-level elapsed time matches the current step's elapsed time // This ensures request-level elapsed time matches the current step's elapsed time
const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber; const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber;

View File

@ -12,10 +12,11 @@ export const sanitizeHtml = (html: string): string => {
// Custom options can be added here if we need to allow specific tags or attributes // Custom options can be added here if we need to allow specific tags or attributes
// For now, using default options which are quite secure // For now, using default options which are quite secure
// Custom options to restrict allowed tags
// By NOT spreading ...whiteList, we explicitly only allow what we define
const options = { const options = {
whiteList: { whiteList: {
...whiteList, // Add only specific tags or attributes required by the frontend
// Add any specific tags or attributes required by the frontend
'span': ['style', 'class'], 'span': ['style', 'class'],
'div': ['style', 'class'], 'div': ['style', 'class'],
'p': ['style', 'class'], 'p': ['style', 'class'],
@ -35,7 +36,9 @@ export const sanitizeHtml = (html: string): string => {
'h5': ['style', 'class'], 'h5': ['style', 'class'],
'h6': ['style', 'class'], 'h6': ['style', 'class'],
'blockquote': ['style', 'class'], 'blockquote': ['style', 'class'],
} },
stripIgnoreTag: true,
stripIgnoreTagBody: ['script']
}; };
const xssFilter = new FilterXSS(options); const xssFilter = new FilterXSS(options);

View File

@ -21,7 +21,7 @@ let workingHoursCacheExpiry: Date | null = null;
*/ */
async function loadWorkingHoursCache(): Promise<void> { async function loadWorkingHoursCache(): Promise<void> {
try { try {
// Reload cache every 5 minutes (shorter than holidays since it's more critical) // Reload cache every 5 minutes (shorter than holidays since it's more important)
if (workingHoursCacheExpiry && new Date() < workingHoursCacheExpiry) { if (workingHoursCacheExpiry && new Date() < workingHoursCacheExpiry) {
return; return;
} }
@ -30,7 +30,7 @@ async function loadWorkingHoursCache(): Promise<void> {
const hours = await getWorkingHours(); const hours = await getWorkingHours();
const startDay = await getConfigNumber('WORK_START_DAY', 1); // Monday const startDay = await getConfigNumber('WORK_START_DAY', 1); // Monday
const endDay = await getConfigNumber('WORK_END_DAY', 5); // Friday const endDay = await getConfigNumber('WORK_END_DAY', 5); // Friday
workingHoursCache = { workingHoursCache = {
startHour: hours.startHour, startHour: hours.startHour,
endHour: hours.endHour, endHour: hours.endHour,
@ -66,7 +66,7 @@ async function loadHolidaysCache(): Promise<void> {
const holidays = await holidayService.getHolidaysInRange(startDate, endDate); const holidays = await holidayService.getHolidaysInRange(startDate, endDate);
holidaysCache = new Set(holidays); holidaysCache = new Set(holidays);
holidaysCacheExpiry = dayjs().add(6, 'hour').toDate(); holidaysCacheExpiry = dayjs().add(6, 'hour').toDate();
} catch (error) { } catch (error) {
console.error('[TAT] Error loading holidays:', error); console.error('[TAT] Error loading holidays:', error);
// Continue without holidays if loading fails // Continue without holidays if loading fails
@ -92,7 +92,7 @@ function isWorkingTime(date: Dayjs): boolean {
if (isTestMode()) { if (isTestMode()) {
return true; return true;
} }
// Use cached working hours (with fallback to TAT_CONFIG) // Use cached working hours (with fallback to TAT_CONFIG)
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
@ -100,25 +100,25 @@ function isWorkingTime(date: Dayjs): boolean {
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
const day = date.day(); // 0 = Sun, 6 = Sat const day = date.day(); // 0 = Sun, 6 = Sat
const hour = date.hour(); const hour = date.hour();
// Check if weekend (based on configured working days) // Check if weekend (based on configured working days)
if (day < config.startDay || day > config.endDay) { if (day < config.startDay || day > config.endDay) {
return false; return false;
} }
// Check if outside working hours (based on configured hours) // Check if outside working hours (based on configured hours)
if (hour < config.startHour || hour >= config.endHour) { if (hour < config.startHour || hour >= config.endHour) {
return false; return false;
} }
// Check if holiday // Check if holiday
if (isHoliday(date)) { if (isHoliday(date)) {
return false; return false;
} }
return true; return true;
} }
@ -130,32 +130,32 @@ function isWorkingTime(date: Dayjs): boolean {
*/ */
export async function addWorkingHours(start: Date | string, hoursToAdd: number): Promise<Dayjs> { export async function addWorkingHours(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
let current = dayjs(start); let current = dayjs(start);
// In test mode, convert hours to minutes for faster testing // In test mode, convert hours to minutes for faster testing
if (isTestMode()) { if (isTestMode()) {
return current.add(hoursToAdd, 'minute'); return current.add(hoursToAdd, 'minute');
} }
// Load working hours and holidays cache if not loaded // Load working hours and holidays cache if not loaded
await loadWorkingHoursCache(); await loadWorkingHoursCache();
await loadHolidaysCache(); await loadHolidaysCache();
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
endHour: TAT_CONFIG.WORK_END_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR,
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
// If start time is before working hours or outside working days/holidays, // If start time is before working hours or outside working days/holidays,
// advance to the next working hour start (reset to clean hour) // advance to the next working hour start (reset to clean hour)
const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
const wasOutsideWorkingHours = !isWorkingTime(current); const wasOutsideWorkingHours = !isWorkingTime(current);
while (!isWorkingTime(current)) { while (!isWorkingTime(current)) {
const hour = current.hour(); const hour = current.hour();
const day = current.day(); const day = current.day();
// If before work start hour on a working day, jump to work start hour // If before work start hour on a working day, jump to work start hour
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) { if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
current = current.hour(config.startHour); current = current.hour(config.startHour);
@ -164,16 +164,16 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
current = current.add(1, 'hour'); current = current.add(1, 'hour');
} }
} }
// If start time was outside working hours, reset to clean work start time (no minutes) // If start time was outside working hours, reset to clean work start time (no minutes)
if (wasOutsideWorkingHours) { if (wasOutsideWorkingHours) {
current = current.minute(0).second(0).millisecond(0); current = current.minute(0).second(0).millisecond(0);
} }
// Split into whole hours and fractional part // Split into whole hours and fractional part
const wholeHours = Math.floor(hoursToAdd); const wholeHours = Math.floor(hoursToAdd);
const fractionalHours = hoursToAdd - wholeHours; const fractionalHours = hoursToAdd - wholeHours;
let remaining = wholeHours; let remaining = wholeHours;
// Add whole hours // Add whole hours
@ -188,7 +188,7 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
if (fractionalHours > 0) { if (fractionalHours > 0) {
const minutesToAdd = Math.round(fractionalHours * 60); const minutesToAdd = Math.round(fractionalHours * 60);
current = current.add(minutesToAdd, 'minute'); current = current.add(minutesToAdd, 'minute');
// Check if fractional addition pushed us outside working time // Check if fractional addition pushed us outside working time
if (!isWorkingTime(current)) { if (!isWorkingTime(current)) {
// Advance to next working period // Advance to next working period
@ -196,7 +196,7 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
current = current.add(1, 'hour'); current = current.add(1, 'hour');
const hour = current.hour(); const hour = current.hour();
const day = current.day(); const day = current.day();
// If before work start hour on a working day, jump to work start hour // If before work start hour on a working day, jump to work start hour
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) { if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
current = current.hour(config.startHour).minute(0).second(0).millisecond(0); current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
@ -217,28 +217,28 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
*/ */
export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: number): Promise<Dayjs> { export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
let current = dayjs(start); let current = dayjs(start);
// In test mode, convert hours to minutes for faster testing // In test mode, convert hours to minutes for faster testing
if (isTestMode()) { if (isTestMode()) {
return current.add(hoursToAdd, 'minute'); return current.add(hoursToAdd, 'minute');
} }
// Load configuration (but don't load holidays - EXPRESS works on holidays too) // Load configuration (but don't load holidays - EXPRESS works on holidays too)
await loadWorkingHoursCache(); await loadWorkingHoursCache();
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
endHour: TAT_CONFIG.WORK_END_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR,
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
// If start time is outside working hours, advance to work start hour (reset to clean hour) // If start time is outside working hours, advance to work start hour (reset to clean hour)
// IMPORTANT: For EXPRESS, we work on ALL days (weekends, holidays), so we don't skip them // IMPORTANT: For EXPRESS, we work on ALL days (weekends, holidays), so we don't skip them
const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
const currentHour = current.hour(); const currentHour = current.hour();
const currentDay = current.day(); // 0 = Sunday, 6 = Saturday const currentDay = current.day(); // 0 = Sunday, 6 = Saturday
if (currentHour < config.startHour) { if (currentHour < config.startHour) {
// Before work hours - jump to work start hour on the same day (even if weekend/holiday) // Before work hours - jump to work start hour on the same day (even if weekend/holiday)
current = current.hour(config.startHour).minute(0).second(0).millisecond(0); current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
@ -246,23 +246,23 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
// After work hours - go to next day's work start hour (even if weekend/holiday) // After work hours - go to next day's work start hour (even if weekend/holiday)
current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0); current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0);
} }
// Split into whole hours and fractional part // Split into whole hours and fractional part
const wholeHours = Math.floor(hoursToAdd); const wholeHours = Math.floor(hoursToAdd);
const fractionalHours = hoursToAdd - wholeHours; const fractionalHours = hoursToAdd - wholeHours;
let remaining = wholeHours; let remaining = wholeHours;
let hoursCounted = 0; let hoursCounted = 0;
// Add whole hours // Add whole hours
// CRITICAL: For EXPRESS, count ALL days (weekends, holidays) - only check working hours (9 AM - 6 PM) //: For EXPRESS, count ALL days (weekends, holidays) - only check working hours (9 AM - 6 PM)
let iterations = 0; let iterations = 0;
const maxIterations = 10000; // Safety limit const maxIterations = 10000; // Safety limit
while (remaining > 0 && iterations < maxIterations) { while (remaining > 0 && iterations < maxIterations) {
current = current.add(1, 'hour'); current = current.add(1, 'hour');
const hour = current.hour(); const hour = current.hour();
// For express: count ALL days (including weekends/holidays) // For express: count ALL days (including weekends/holidays)
// But only during working hours (configured start - end hour) // But only during working hours (configured start - end hour)
// NO checks for day of week or holidays - EXPRESS works 7 days a week // NO checks for day of week or holidays - EXPRESS works 7 days a week
@ -273,16 +273,16 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
// This ensures we only count 9 AM - 6 PM on any day // This ensures we only count 9 AM - 6 PM on any day
iterations++; iterations++;
} }
if (iterations >= maxIterations) { if (iterations >= maxIterations) {
console.error(`[EXPRESS TAT] Safety break - exceeded ${maxIterations} iterations`); console.error(`[EXPRESS TAT] Safety break - exceeded ${maxIterations} iterations`);
} }
// Add fractional part (convert to minutes) // Add fractional part (convert to minutes)
if (fractionalHours > 0) { if (fractionalHours > 0) {
const minutesToAdd = Math.round(fractionalHours * 60); const minutesToAdd = Math.round(fractionalHours * 60);
current = current.add(minutesToAdd, 'minute'); current = current.add(minutesToAdd, 'minute');
// Check if fractional addition pushed us past working hours // Check if fractional addition pushed us past working hours
if (current.hour() >= config.endHour) { if (current.hour() >= config.endHour) {
// Overflow to next day's working hours (even if weekend/holiday) // Overflow to next day's working hours (even if weekend/holiday)
@ -290,7 +290,7 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
current = current.add(1, 'day').hour(config.startHour).minute(excessMinutes).second(0).millisecond(0); current = current.add(1, 'day').hour(config.startHour).minute(excessMinutes).second(0).millisecond(0);
} }
} }
return current; return current;
} }
@ -300,12 +300,12 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
*/ */
export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayjs { export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayjs {
let current = dayjs(start); let current = dayjs(start);
// In test mode, convert hours to minutes for faster testing // In test mode, convert hours to minutes for faster testing
if (isTestMode()) { if (isTestMode()) {
return current.add(hoursToAdd, 'minute'); return current.add(hoursToAdd, 'minute');
} }
// Simply add hours without any exclusions (24/7) // Simply add hours without any exclusions (24/7)
return current.add(hoursToAdd, 'hour'); return current.add(hoursToAdd, 'hour');
} }
@ -317,12 +317,12 @@ export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayj
*/ */
export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): Dayjs { export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): Dayjs {
let current = dayjs(start); let current = dayjs(start);
// In test mode, convert hours to minutes for faster testing // In test mode, convert hours to minutes for faster testing
if (isTestMode()) { if (isTestMode()) {
return current.add(hoursToAdd, 'minute'); return current.add(hoursToAdd, 'minute');
} }
// Use cached working hours with fallback // Use cached working hours with fallback
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
@ -330,16 +330,16 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
// If start time is before working hours or outside working days, // If start time is before working hours or outside working days,
// advance to the next working hour start (reset to clean hour) // advance to the next working hour start (reset to clean hour)
const originalStart = current.format('YYYY-MM-DD HH:mm:ss'); const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
let hour = current.hour(); let hour = current.hour();
let day = current.day(); let day = current.day();
// Check if originally outside working hours // Check if originally outside working hours
const wasOutsideWorkingHours = !(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour); const wasOutsideWorkingHours = !(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour);
// If before work start hour on a working day, jump to work start hour // If before work start hour on a working day, jump to work start hour
if (day >= config.startDay && day <= config.endDay && hour < config.startHour) { if (day >= config.startDay && day <= config.endDay && hour < config.startHour) {
current = current.hour(config.startHour); current = current.hour(config.startHour);
@ -351,12 +351,12 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
hour = current.hour(); hour = current.hour();
} }
} }
// If start time was outside working hours, reset to clean work start time // If start time was outside working hours, reset to clean work start time
if (wasOutsideWorkingHours) { if (wasOutsideWorkingHours) {
current = current.minute(0).second(0).millisecond(0); current = current.minute(0).second(0).millisecond(0);
} }
let remaining = hoursToAdd; let remaining = hoursToAdd;
while (remaining > 0) { while (remaining > 0) {
@ -364,8 +364,8 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
const day = current.day(); const day = current.day();
const hour = current.hour(); const hour = current.hour();
// Simple check without holidays (but respects configured working hours) // Simple check without holidays (but respects configured working hours)
if (day >= config.startDay && day <= config.endDay && if (day >= config.startDay && day <= config.endDay &&
hour >= config.startHour && hour < config.endHour) { hour >= config.startHour && hour < config.endHour) {
remaining -= 1; remaining -= 1;
} }
} }
@ -388,7 +388,7 @@ export async function initializeHolidaysCache(): Promise<void> {
export async function clearWorkingHoursCache(): Promise<void> { export async function clearWorkingHoursCache(): Promise<void> {
workingHoursCache = null; workingHoursCache = null;
workingHoursCacheExpiry = null; workingHoursCacheExpiry = null;
// Immediately reload the cache with new values // Immediately reload the cache with new values
await loadWorkingHoursCache(); await loadWorkingHoursCache();
} }
@ -442,36 +442,36 @@ export function calculateDelay(targetDate: Date): number {
export async function isCurrentlyWorkingTime(priority: string = 'standard'): Promise<boolean> { export async function isCurrentlyWorkingTime(priority: string = 'standard'): Promise<boolean> {
await loadWorkingHoursCache(); await loadWorkingHoursCache();
await loadHolidaysCache(); await loadHolidaysCache();
const now = dayjs(); const now = dayjs();
// In test mode, always working time // In test mode, always working time
if (isTestMode()) { if (isTestMode()) {
return true; return true;
} }
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
endHour: TAT_CONFIG.WORK_END_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR,
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
const day = now.day(); const day = now.day();
const hour = now.hour(); const hour = now.hour();
const dateStr = now.format('YYYY-MM-DD'); const dateStr = now.format('YYYY-MM-DD');
// Check working hours // Check working hours
const isWorkingHour = hour >= config.startHour && hour < config.endHour; const isWorkingHour = hour >= config.startHour && hour < config.endHour;
// For express: include weekends, for standard: exclude weekends // For express: include weekends, for standard: exclude weekends
const isWorkingDay = priority === 'express' const isWorkingDay = priority === 'express'
? true ? true
: (day >= config.startDay && day <= config.endDay); : (day >= config.startDay && day <= config.endDay);
// Check if not a holiday // Check if not a holiday
const isNotHoliday = !holidaysCache.has(dateStr); const isNotHoliday = !holidaysCache.has(dateStr);
return isWorkingDay && isWorkingHour && isNotHoliday; return isWorkingDay && isWorkingHour && isNotHoliday;
} }
@ -488,16 +488,16 @@ export async function calculateSLAStatus(
) { ) {
await loadWorkingHoursCache(); await loadWorkingHoursCache();
await loadHolidaysCache(); await loadHolidaysCache();
const startDate = dayjs(levelStartTime); const startDate = dayjs(levelStartTime);
// Use provided endDate if available (for completed requests), otherwise use current time // Use provided endDate if available (for completed requests), otherwise use current time
const endTime = endDate ? dayjs(endDate) : dayjs(); const endTime = endDate ? dayjs(endDate) : dayjs();
// Calculate elapsed working hours (with pause handling) // Calculate elapsed working hours (with pause handling)
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority, pauseInfo); const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority, pauseInfo);
const remainingHours = Math.max(0, tatHours - elapsedHours); const remainingHours = Math.max(0, tatHours - elapsedHours);
const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0; const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0;
// Calculate deadline based on priority // Calculate deadline based on priority
// EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM) // EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM)
// STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM) // STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM)
@ -523,13 +523,13 @@ export async function calculateSLAStatus(
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate() ? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
: (await addWorkingHours(levelStartTime, tatHours)).toDate(); : (await addWorkingHours(levelStartTime, tatHours)).toDate();
} }
// Check if currently paused (workflow pause or outside working hours) // Check if currently paused (workflow pause or outside working hours)
// For completed requests (with endDate), it's not paused // For completed requests (with endDate), it's not paused
const isPaused = endDate const isPaused = endDate
? false ? false
: (pauseInfo?.isPaused === true || !(await isCurrentlyWorkingTime(priority))); : (pauseInfo?.isPaused === true || !(await isCurrentlyWorkingTime(priority)));
// Determine status // Determine status
let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track'; let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
if (percentageUsed >= 100) { if (percentageUsed >= 100) {
@ -539,22 +539,22 @@ export async function calculateSLAStatus(
} else if (percentageUsed >= 60) { } else if (percentageUsed >= 60) {
status = 'approaching'; status = 'approaching';
} }
// Format remaining time // Format remaining time
const formatTime = (hours: number) => { const formatTime = (hours: number) => {
if (hours <= 0) return '0h'; if (hours <= 0) return '0h';
const days = Math.floor(hours / 8); // 8 working hours per day const days = Math.floor(hours / 8); // 8 working hours per day
const remainingHrs = Math.floor(hours % 8); const remainingHrs = Math.floor(hours % 8);
const minutes = Math.round((hours % 1) * 60); const minutes = Math.round((hours % 1) * 60);
if (days > 0) { if (days > 0) {
return minutes > 0 return minutes > 0
? `${days}d ${remainingHrs}h ${minutes}m` ? `${days}d ${remainingHrs}h ${minutes}m`
: `${days}d ${remainingHrs}h`; : `${days}d ${remainingHrs}h`;
} }
return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`; return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`;
}; };
return { return {
elapsedHours: Math.round(elapsedHours * 100) / 100, elapsedHours: Math.round(elapsedHours * 100) / 100,
remainingHours: Math.round(remainingHours * 100) / 100, remainingHours: Math.round(remainingHours * 100) / 100,
@ -577,26 +577,26 @@ export async function calculateSLAStatus(
* @returns Elapsed working hours (with decimal precision) * @returns Elapsed working hours (with decimal precision)
*/ */
export async function calculateElapsedWorkingHours( export async function calculateElapsedWorkingHours(
startDate: Date | string, startDate: Date | string,
endDateParam: Date | string | null = null, endDateParam: Date | string | null = null,
priority: string = 'standard', priority: string = 'standard',
pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null } pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null }
): Promise<number> { ): Promise<number> {
await loadWorkingHoursCache(); await loadWorkingHoursCache();
await loadHolidaysCache(); await loadHolidaysCache();
// Handle pause: if paused, use elapsed hours at pause time // Handle pause: if paused, use elapsed hours at pause time
if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) { if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) {
// If currently paused, return the elapsed hours at pause time // If currently paused, return the elapsed hours at pause time
// No additional time accumulates while paused // No additional time accumulates while paused
return pauseInfo.pauseElapsedHours; return pauseInfo.pauseElapsedHours;
} }
// If was paused but now resumed, calculate from resume date // If was paused but now resumed, calculate from resume date
let actualStartDate = startDate; let actualStartDate = startDate;
let prePauseElapsed = 0; let prePauseElapsed = 0;
let resumeTime = null; let resumeTime = null;
if (pauseInfo?.pauseResumeDate && pauseInfo.pauseElapsedHours !== undefined) { if (pauseInfo?.pauseResumeDate && pauseInfo.pauseElapsedHours !== undefined) {
// Was paused, now resumed // Was paused, now resumed
// Use elapsed hours at pause + time from resume to end // Use elapsed hours at pause + time from resume to end
@ -604,45 +604,45 @@ export async function calculateElapsedWorkingHours(
actualStartDate = pauseInfo.pauseResumeDate; actualStartDate = pauseInfo.pauseResumeDate;
resumeTime = pauseInfo.pauseResumeDate; // Store resume time for reference resumeTime = pauseInfo.pauseResumeDate; // Store resume time for reference
} }
let start = dayjs(actualStartDate); let start = dayjs(actualStartDate);
const end = dayjs(endDateParam || new Date()); const end = dayjs(endDateParam || new Date());
// In test mode, use raw minutes for 1:1 conversion // In test mode, use raw minutes for 1:1 conversion
if (isTestMode()) { if (isTestMode()) {
const postResumeHours = end.diff(start, 'minute') / 60; const postResumeHours = end.diff(start, 'minute') / 60;
return prePauseElapsed + postResumeHours; return prePauseElapsed + postResumeHours;
} }
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
endHour: TAT_CONFIG.WORK_END_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR,
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
// CRITICAL: For resumed levels, we must use the exact resume time as start //: For resumed levels, we must use the exact resume time as start
// Do NOT advance resume time to next working period - resume time is the actual moment TAT resumed // Do NOT advance resume time to next working period - resume time is the actual moment TAT resumed
// Only advance if we're calculating from original start (not resumed) // Only advance if we're calculating from original start (not resumed)
const isResumedLevel = resumeTime !== null; const isResumedLevel = resumeTime !== null;
if (!isResumedLevel) { if (!isResumedLevel) {
// Only adjust start time if this is NOT a resumed level // Only adjust start time if this is NOT a resumed level
// For resumed levels, use exact resume time (even if outside working hours) // For resumed levels, use exact resume time (even if outside working hours)
// The working hours calculation below will handle skipping non-working periods // The working hours calculation below will handle skipping non-working periods
// CRITICAL FIX: If start time is outside working hours, advance to next working period // FIX: If start time is outside working hours, advance to next working period
// This ensures we only count elapsed time when TAT is actually running // This ensures we only count elapsed time when TAT is actually running
const originalStart = start.format('YYYY-MM-DD HH:mm:ss'); const originalStart = start.format('YYYY-MM-DD HH:mm:ss');
// For standard priority, check working days and hours // For standard priority, check working days and hours
if (priority !== 'express') { if (priority !== 'express') {
const wasOutsideWorkingHours = !isWorkingTime(start); const wasOutsideWorkingHours = !isWorkingTime(start);
while (!isWorkingTime(start)) { while (!isWorkingTime(start)) {
const hour = start.hour(); const hour = start.hour();
const day = start.day(); const day = start.day();
// If before work start hour on a working day, jump to work start hour // If before work start hour on a working day, jump to work start hour
if (day >= config.startDay && day <= config.endDay && !isHoliday(start) && hour < config.startHour) { if (day >= config.startDay && day <= config.endDay && !isHoliday(start) && hour < config.startHour) {
start = start.hour(config.startHour); start = start.hour(config.startHour);
@ -651,7 +651,7 @@ export async function calculateElapsedWorkingHours(
start = start.add(1, 'hour'); start = start.add(1, 'hour');
} }
} }
// If start time was outside working hours, reset to clean work start time // If start time was outside working hours, reset to clean work start time
if (wasOutsideWorkingHours) { if (wasOutsideWorkingHours) {
start = start.minute(0).second(0).millisecond(0); start = start.minute(0).second(0).millisecond(0);
@ -669,31 +669,31 @@ export async function calculateElapsedWorkingHours(
} }
} }
// For resumed levels, keep the exact resume time - the day-by-day calculation below will handle working hours correctly // For resumed levels, keep the exact resume time - the day-by-day calculation below will handle working hours correctly
if (end.isBefore(start)) { if (end.isBefore(start)) {
return 0; return 0;
} }
let totalWorkingMinutes = 0; let totalWorkingMinutes = 0;
let currentDate = start.startOf('day'); let currentDate = start.startOf('day');
const endDay = end.startOf('day'); const endDay = end.startOf('day');
// Process each day // Process each day
while (currentDate.isBefore(endDay) || currentDate.isSame(endDay, 'day')) { while (currentDate.isBefore(endDay) || currentDate.isSame(endDay, 'day')) {
const dateStr = currentDate.format('YYYY-MM-DD'); const dateStr = currentDate.format('YYYY-MM-DD');
const dayOfWeek = currentDate.day(); const dayOfWeek = currentDate.day();
// Check if this day is a working day // Check if this day is a working day
const isWorkingDay = priority === 'express' const isWorkingDay = priority === 'express'
? true ? true
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
const isNotHoliday = !holidaysCache.has(dateStr); const isNotHoliday = !holidaysCache.has(dateStr);
if (isWorkingDay && isNotHoliday) { if (isWorkingDay && isNotHoliday) {
// Determine the working period for this day // Determine the working period for this day
let dayStart = currentDate.hour(config.startHour).minute(0).second(0); let dayStart = currentDate.hour(config.startHour).minute(0).second(0);
let dayEnd = currentDate.hour(config.endHour).minute(0).second(0); let dayEnd = currentDate.hour(config.endHour).minute(0).second(0);
// Adjust for first day (might start mid-day) // Adjust for first day (might start mid-day)
if (currentDate.isSame(start, 'day')) { if (currentDate.isSame(start, 'day')) {
if (start.hour() >= config.endHour) { if (start.hour() >= config.endHour) {
@ -706,7 +706,7 @@ export async function calculateElapsedWorkingHours(
} }
// If before work hours, dayStart is already correct (work start time) // If before work hours, dayStart is already correct (work start time)
} }
// Adjust for last day (might end mid-day) // Adjust for last day (might end mid-day)
if (currentDate.isSame(end, 'day')) { if (currentDate.isSame(end, 'day')) {
if (end.hour() < config.startHour) { if (end.hour() < config.startHour) {
@ -719,25 +719,25 @@ export async function calculateElapsedWorkingHours(
} }
// If after work hours, dayEnd is already correct (work end time) // If after work hours, dayEnd is already correct (work end time)
} }
// Calculate minutes worked this day // Calculate minutes worked this day
if (dayStart.isBefore(dayEnd)) { if (dayStart.isBefore(dayEnd)) {
const minutesThisDay = dayEnd.diff(dayStart, 'minute'); const minutesThisDay = dayEnd.diff(dayStart, 'minute');
totalWorkingMinutes += minutesThisDay; totalWorkingMinutes += minutesThisDay;
} }
} }
currentDate = currentDate.add(1, 'day'); currentDate = currentDate.add(1, 'day');
// Safety check // Safety check
if (currentDate.diff(start, 'day') > 730) { // 2 years if (currentDate.diff(start, 'day') > 730) { // 2 years
console.error('[TAT] Safety break - exceeded 2 years'); console.error('[TAT] Safety break - exceeded 2 years');
break; break;
} }
} }
const hours = totalWorkingMinutes / 60; const hours = totalWorkingMinutes / 60;
// Add pre-pause elapsed hours if resumed // Add pre-pause elapsed hours if resumed
return prePauseElapsed + hours; return prePauseElapsed + hours;
} }
@ -757,51 +757,51 @@ export async function calculateBusinessDays(
): Promise<number> { ): Promise<number> {
await loadWorkingHoursCache(); await loadWorkingHoursCache();
await loadHolidaysCache(); await loadHolidaysCache();
let start = dayjs(startDate).startOf('day'); let start = dayjs(startDate).startOf('day');
const end = dayjs(endDate || new Date()).startOf('day'); const end = dayjs(endDate || new Date()).startOf('day');
// In test mode, use calendar days // In test mode, use calendar days
if (isTestMode()) { if (isTestMode()) {
return end.diff(start, 'day') + 1; return end.diff(start, 'day') + 1;
} }
const config = workingHoursCache || { const config = workingHoursCache || {
startHour: TAT_CONFIG.WORK_START_HOUR, startHour: TAT_CONFIG.WORK_START_HOUR,
endHour: TAT_CONFIG.WORK_END_HOUR, endHour: TAT_CONFIG.WORK_END_HOUR,
startDay: TAT_CONFIG.WORK_START_DAY, startDay: TAT_CONFIG.WORK_START_DAY,
endDay: TAT_CONFIG.WORK_END_DAY endDay: TAT_CONFIG.WORK_END_DAY
}; };
let businessDays = 0; let businessDays = 0;
let current = start; let current = start;
// Count each day from start to end (inclusive) // Count each day from start to end (inclusive)
while (current.isBefore(end) || current.isSame(end, 'day')) { while (current.isBefore(end) || current.isSame(end, 'day')) {
const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday
const dateStr = current.format('YYYY-MM-DD'); const dateStr = current.format('YYYY-MM-DD');
// For express priority: count all days (including weekends) but exclude holidays // For express priority: count all days (including weekends) but exclude holidays
// For standard priority: count only working days (Mon-Fri) and exclude holidays // For standard priority: count only working days (Mon-Fri) and exclude holidays
const isWorkingDay = priority === 'express' const isWorkingDay = priority === 'express'
? true // Express includes weekends ? true // Express includes weekends
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
const isNotHoliday = !holidaysCache.has(dateStr); const isNotHoliday = !holidaysCache.has(dateStr);
if (isWorkingDay && isNotHoliday) { if (isWorkingDay && isNotHoliday) {
businessDays++; businessDays++;
} }
current = current.add(1, 'day'); current = current.add(1, 'day');
// Safety check to prevent infinite loops // Safety check to prevent infinite loops
if (current.diff(start, 'day') > 730) { // 2 years if (current.diff(start, 'day') > 730) { // 2 years
console.error('[TAT] Safety break - exceeded 2 years in business days calculation'); console.error('[TAT] Safety break - exceeded 2 years in business days calculation');
break; break;
} }
} }
return businessDays; return businessDays;
} }