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_PORT=587
SMTP_SECURE=false
SMTP_USER=notifications@{{API_DOMAIN}}
SMTP_USER=notifications@{{APP_DOMAIN}}
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_API_KEY=your_ai_api_key

View File

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

View File

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

View File

@ -66,7 +66,7 @@
],
"body": {
"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": {
"raw": "{{baseUrl}}/auth/login",
@ -498,7 +498,7 @@
],
"body": {
"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": {
"raw": "{{baseUrl}}/workflows",
@ -522,7 +522,7 @@
"formdata": [
{
"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",
"description": "JSON payload with simplified format (email + tatHours only)"
},
@ -719,7 +719,7 @@
],
"body": {
"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": {
"raw": "{{baseUrl}}/workflows/:id/approvers/at-level",
@ -755,7 +755,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"spectator@{{API_DOMAIN}}\"\n}"
"raw": "{\n \"email\": \"spectator@{{APP_DOMAIN}}\"\n}"
},
"url": {
"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};
//# sourceMappingURL=conclusionApi-DoX_H3Tk.js.map
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};

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

View File

@ -112,7 +112,7 @@ Your CSV file must have these **44 columns** in the following order:
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{API_DOMAIN}}) |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
| `replacement` | String(50) | No | Replacement status |
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
```csv
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{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:**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ await this.createClaimApprovalLevels(
isAuto: false,
approverType: 'department_lead' as const,
approverId: departmentLead?.userId || null,
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@{{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
{
"username": "john.doe@{{API_DOMAIN}}",
"username": "john.doe@{{APP_DOMAIN}}",
"password": "SecurePassword123!"
}
```

View File

@ -41,9 +41,9 @@ USE_GOOGLE_SECRET_MANAGER=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=notifications@{{API_DOMAIN}}
SMTP_USER=notifications@{{APP_DOMAIN}}
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
# Uses service account credentials from GCP_KEY_FILE

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
All templates feature a single action button:
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
- **Link Format:** `{baseURL}/request/{requestNumber}`
- **Example:** `https://workflow.{{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.
@ -231,8 +231,8 @@ SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password
# Email Settings
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}>
BASE_URL=https://workflow.{{API_DOMAIN}}
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield
```

View File

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

View File

@ -53,7 +53,7 @@ const data: RequestCreatedData = {
requestTime: '02:30 PM',
totalApprovers: 3,
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'
};
```
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password
# Email Settings
EMAIL_FROM=Royal Enfield Workflow <notifications@{{API_DOMAIN}}>
EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
# Application Settings
BASE_URL=https://workflow.{{API_DOMAIN}}
BASE_URL=https://workflow.{{APP_DOMAIN}}
COMPANY_NAME=Royal Enfield
```

View File

@ -7,18 +7,20 @@
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
/**
* Company Information
*/
export const CompanyInfo = {
name: 'Royal Enfield',
productName: 'RE Flow', // Product name displayed in header
website: 'https://www.{{API_DOMAIN}}',
supportEmail: 'support@{{API_DOMAIN}}',
website: `https://www.${appDomain}`,
supportEmail: `support@${appDomain}`,
// Logo configuration for email headers
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',
width: 220, // Logo width in pixels (wider for better visibility)
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
@ -88,7 +90,7 @@ export const CustomHeaderStyles = {
* Usage in email service:
* 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 {
return `${frontendUrl}/request/${requestNumber}`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,9 @@ import { getPublicConfigurations } from '../controllers/admin.controller';
const router = Router();
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>
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)
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;

View File

@ -298,6 +298,7 @@ async function autoSetup(): Promise<void> {
// Step 0: Initialize secrets
console.log('🔐 Initializing secrets...');
await initializeGoogleSecretManager();
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Step 1: Check and create database if needed
const wasCreated = await checkAndCreateDatabase();
@ -323,7 +324,7 @@ async function autoSetup(): Promise<void> {
console.log(' 1. Server will start automatically');
console.log(' 2. Log in via SSO');
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) {

View File

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

View File

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

View File

@ -12,6 +12,8 @@ import { notificationService } from './notification.service';
import { activityService } from './activity.service';
import { tatSchedulerService } from './tatScheduler.service';
import { emitToRequestRoom } from '../realtime/socket';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
export class ApprovalService {
@ -538,19 +540,19 @@ export class ApprovalService {
// 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
// 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).approverId === 'system';
// 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
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
// Additional checks: ensure approverEmail and approverName are not system-related
// This prevents notifications to system accounts even if they pass other checks
const approverEmail = (nextLevel as any).approverEmail || '';
const approverName = (nextLevel as any).approverName || '';
const isSystemEmail = approverEmail.toLowerCase() === 'system@{{API_DOMAIN}}'
const isSystemEmail = approverEmail.toLowerCase() === `system@${appDomain}`
|| approverEmail.toLowerCase().includes('system');
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|| approverName.toLowerCase().includes('system');

View File

@ -26,7 +26,7 @@ import { dmsIntegrationService } from './dmsIntegration.service';
import { findDealerLocally } from './dealer.service';
import logger from '../utils/logger';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
let workflowServiceInstance: any;
@ -171,7 +171,7 @@ export class DealerClaimService {
levelNumber: a.level,
levelName: levelName,
approverId: approverUserId || '', // Fallback to empty string if still not resolved
approverEmail: a.email,
approverEmail: `system@${appDomain}`,
approverName: a.name || a.email,
tatHours: tatHours,
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
@ -330,7 +330,9 @@ export class DealerClaimService {
let stepDef = null;
// 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) {
// 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)
if (dealerEmail && dealerEmail.toLowerCase() !== 'system@{{API_DOMAIN}}') {
if (dealerEmail && dealerEmail.toLowerCase() !== `system@${appDomain}`) {
let dealerUser = await User.findOne({
where: { email: dealerEmail.toLowerCase() },
});
@ -626,7 +628,7 @@ export class DealerClaimService {
// 3. Add all approvers from approval levels (excluding system and duplicates)
const addedUserIds = new Set<string>([initiatorId]);
const systemEmails = ['system@{{API_DOMAIN}}'];
const systemEmails = [`system@${appDomain}`];
for (const level of approvalLevels) {
const approverEmail = (level as any).approverEmail?.toLowerCase();
@ -3163,7 +3165,7 @@ export class DealerClaimService {
deptLeadLevel.levelName || undefined
);
} 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);
}
@ -3286,7 +3288,7 @@ export class DealerClaimService {
previousLevel.levelName || undefined
);
} 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);
}

View File

@ -25,6 +25,8 @@ import { tatSchedulerService } from './tatScheduler.service';
import { DealerClaimService } from './dealerClaim.service';
import { emitToRequestRoom } from '../realtime/socket';
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
let dealerClaimServiceInstance: any;
@ -147,7 +149,7 @@ export class DealerClaimApprovalService {
userId
);
} 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);
}
}
@ -399,11 +401,11 @@ export class DealerClaimApprovalService {
const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver';
// 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'
|| nextApproverId === 'system';
const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@{{API_DOMAIN}}'
const isSystemEmail = nextApproverEmail.toLowerCase() === `system@${appDomain}`
|| nextApproverEmail.toLowerCase().includes('system');
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|| nextApproverName.toLowerCase().includes('system');
@ -747,7 +749,7 @@ export class DealerClaimApprovalService {
level.levelName || undefined
);
} 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);
}
@ -962,4 +964,3 @@ export class DealerClaimApprovalService {
});
}
}

View File

@ -17,11 +17,7 @@ interface EmailOptions {
attachments?: any[];
}
// Hardcoded BCC addresses (temporary - for time being)
const HARDCODED_BCC: string[] = [
'{{USER_EMAIL}}',
// Add your BCC email addresses here
];
export class EmailService {
private transporter: nodemailer.Transporter | null = null;
@ -44,7 +40,7 @@ export class EmailService {
return;
}
// Production SMTP configuration
// Prod SMTP configuration
try {
this.transporter = nodemailer.createTransport({
host: smtpHost,
@ -119,24 +115,13 @@ export class EmailService {
}
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
const fromAddress = process.env.EMAIL_FROM || 'RE Flow <noreply@{{API_DOMAIN}}>';
// 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 fromAddress = process.env.EMAIL_FROM || `RE Flow <noreply@${process.env.APP_DOMAIN || 'royalenfield.com'}>`;
const mailOptions = {
from: fromAddress,
to: recipients,
cc: options.cc,
bcc: finalBcc,
bcc: options.bcc || [],
subject: options.subject,
html: options.html,
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(
requestData: any,
@ -298,7 +298,7 @@ export class EmailNotificationService {
rejectionReason: string
): Promise<void> {
try {
// Use override for critical emails
// Use override for high-priority emails
const canSend = await shouldSendEmailWithOverride(
initiatorData.userId,
EmailNotificationType.REQUEST_REJECTED
@ -416,7 +416,7 @@ export class EmailNotificationService {
}
/**
* 6. Send TAT Breached Email (CRITICAL)
* 6. Send TAT Breached Email (ESSENTIAL)
*/
async sendTATBreached(
requestData: any,
@ -428,7 +428,7 @@ export class EmailNotificationService {
}
): Promise<void> {
try {
// Use override for critical emails
// Use override for high-priority emails
const canSend = await shouldSendEmailWithOverride(
approverData.userId,
EmailNotificationType.TAT_BREACHED
@ -1086,9 +1086,9 @@ export class EmailNotificationService {
}
// Check if next approver is the recipient (initiator reviewing their own request)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const data: DealerProposalSubmittedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
@ -1102,7 +1102,7 @@ export class EmailNotificationService {
costBreakupSummary: costBreakupSummary,
submittedDate: this.formatDate(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
: (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
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)
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApproverData && nextApproverData.userId === recipientData.userId);
const data: CompletionDocumentsSubmittedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
@ -1234,7 +1234,7 @@ export class EmailNotificationService {
documentsCount: completionData.documentsCount,
submittedDate: this.formatDate(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
: (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),

View File

@ -23,13 +23,15 @@ interface NotificationPayload {
metadata?: any;
}
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
class NotificationService {
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
const pub = vapidPublicKey || process.env.VAPID_PUBLIC_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) {
logger.warn('VAPID keys are not configured. Push notifications are disabled.');
return;
@ -319,12 +321,12 @@ class NotificationService {
}
// 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' ||
payload.type === 'tat_breach' ||
payload.type === 'breach';
const shouldSend = isCriticalEmail
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
? await shouldSendEmailWithOverride(userId, emailType) // emails
: payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails
@ -569,7 +571,7 @@ class NotificationService {
approverData = {
userId: (rejectedLevel as any).approverId,
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,
comments: (rejectedLevel as any).comments
};
@ -612,7 +614,7 @@ class NotificationService {
approverData = {
userId: (currentLevel as any).approverId,
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 = {
userId: (currentLevel as any).approverId,
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 = {
userId: (pausedLevel as any).approverId,
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 = {
userId: (currentLevel as any).approverId,
displayName: (currentLevel as any).approverName || 'Unknown User',
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}'
email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
};
}
} else {

View File

@ -71,7 +71,7 @@ export class PdfService {
private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const logoUrl = '{{LOGO_URL}}';
const logoUrl = `{{LOGO_URL}}`;
return `
<!DOCTYPE html>
@ -119,7 +119,7 @@ export class PdfService {
<div class="info-grid">
<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 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>
<br/>
<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
* Handles communication with PWC API for signed invoice generation
*/
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
export class PWCIntegrationService {
private apiUrl: string;
private customerId: string;
@ -123,7 +125,7 @@ export class PWCIntegrationService {
SourceSystem: "RE_WORKFLOW",
is_irn: "Y",
is_ewb: "N",
email: (request as any).initiator?.email || "system@{{API_DOMAIN}}",
email: (request as any).initiator?.email || `system@${appDomain}`,
TranDtls: {
TaxSch: "GST",
SubType: "SUPPLY",

View File

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

View File

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

View File

@ -118,7 +118,7 @@ export class WorkflowService {
requestId,
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',
actionRequired: true // Approvers need to take action
});
@ -506,7 +506,7 @@ export class WorkflowService {
requestId,
requestNumber: (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',
actionRequired: true // Additional approvers need to take action
});
@ -597,7 +597,7 @@ export class WorkflowService {
requestId,
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)
metadata: {
addedBy: addedBy // Used in email to show who added the spectator
@ -3016,7 +3016,7 @@ export class WorkflowService {
if (submissionDate && totalTatHours > 0) {
// 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
// This ensures that when in step 1, only step 1's elapsed hours are counted
let totalElapsedHours = 0;
@ -3033,7 +3033,7 @@ export class WorkflowService {
// Skipped levels don't contribute to elapsed time
continue;
} 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
// This ensures request-level elapsed time matches the current step's elapsed time
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
// 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 = {
whiteList: {
...whiteList,
// Add any specific tags or attributes required by the frontend
// Add only specific tags or attributes required by the frontend
'span': ['style', 'class'],
'div': ['style', 'class'],
'p': ['style', 'class'],
@ -35,7 +36,9 @@ export const sanitizeHtml = (html: string): string => {
'h5': ['style', 'class'],
'h6': ['style', 'class'],
'blockquote': ['style', 'class'],
}
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script']
};
const xssFilter = new FilterXSS(options);

View File

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