vulnnearable comments removed and source exposing to frobrowser disabled worknote XSS fixed
This commit is contained in:
parent
b32a3505ac
commit
00e1d51c66
@ -1326,9 +1326,9 @@ GCP_KEY_FILE=./config/gcp-key.json
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{API_DOMAIN}}
|
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||||
SMTP_PASSWORD=your_smtp_password
|
SMTP_PASSWORD=your_smtp_password
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||||
|
|
||||||
# AI Service (for conclusion generation)
|
# AI Service (for conclusion generation)
|
||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
|
|||||||
@ -98,7 +98,7 @@ npm run dev
|
|||||||
1. Server will start automatically
|
1. Server will start automatically
|
||||||
2. Log in via SSO
|
2. Log in via SSO
|
||||||
3. Run this SQL to make yourself admin:
|
3. Run this SQL to make yourself admin:
|
||||||
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{API_DOMAIN}}';
|
UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
[Config Seed] ✅ Default configurations seeded successfully (30 settings)
|
[Config Seed] ✅ Default configurations seeded successfully (30 settings)
|
||||||
info: ✅ Server started successfully on port 5000
|
info: ✅ Server started successfully on port 5000
|
||||||
@ -112,7 +112,7 @@ psql -d royal_enfield_workflow
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'your-email@{{API_DOMAIN}}';
|
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|||||||
@ -471,7 +471,7 @@ The backend supports web push notifications via VAPID (Voluntary Application Ser
|
|||||||
```
|
```
|
||||||
VAPID_PUBLIC_KEY=<your-public-key>
|
VAPID_PUBLIC_KEY=<your-public-key>
|
||||||
VAPID_PRIVATE_KEY=<your-private-key>
|
VAPID_PRIVATE_KEY=<your-private-key>
|
||||||
VAPID_CONTACT=mailto:admin@{{API_DOMAIN}}
|
VAPID_CONTACT=mailto:admin@{{APP_DOMAIN}}
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Add to Frontend `.env`:**
|
3. **Add to Frontend `.env`:**
|
||||||
|
|||||||
@ -66,7 +66,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n // Okta username (email)\n \"username\": \"user@{{API_DOMAIN}}\",\n \n // Okta password\n \"password\": \"YourOktaPassword123\"\n}"
|
"raw": "{\n // Okta username (email)\n \"username\": \"user@{{APP_DOMAIN}}\",\n \n // Okta password\n \"password\": \"YourOktaPassword123\"\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/auth/login",
|
"raw": "{{baseUrl}}/auth/login",
|
||||||
@ -498,7 +498,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"templateType\": \"CUSTOM\",\n \"title\": \"Purchase Order Approval for Office Equipment\",\n \"description\": \"Approval needed for purchasing new office equipment including laptops, monitors, and office furniture. Total budget: $50,000\",\n \"priority\": \"STANDARD\",\n \"approvalLevels\": [\n {\n \"email\": \"manager@{{API_DOMAIN}}\",\n \"tatHours\": 24\n },\n {\n \"email\": \"director@{{API_DOMAIN}}\",\n \"tatHours\": 48\n },\n {\n \"email\": \"cfo@{{API_DOMAIN}}\",\n \"tatHours\": 72\n }\n ],\n \"spectators\": [\n {\n \"email\": \"hr@{{API_DOMAIN}}\"\n },\n {\n \"email\": \"finance@{{API_DOMAIN}}\"\n }\n ]\n}"
|
"raw": "{\n \"templateType\": \"CUSTOM\",\n \"title\": \"Purchase Order Approval for Office Equipment\",\n \"description\": \"Approval needed for purchasing new office equipment including laptops, monitors, and office furniture. Total budget: $50,000\",\n \"priority\": \"STANDARD\",\n \"approvalLevels\": [\n {\n \"email\": \"manager@{{APP_DOMAIN}}\",\n \"tatHours\": 24\n },\n {\n \"email\": \"director@{{APP_DOMAIN}}\",\n \"tatHours\": 48\n },\n {\n \"email\": \"cfo@{{APP_DOMAIN}}\",\n \"tatHours\": 72\n }\n ],\n \"spectators\": [\n {\n \"email\": \"hr@{{APP_DOMAIN}}\"\n },\n {\n \"email\": \"finance@{{APP_DOMAIN}}\"\n }\n ]\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/workflows",
|
"raw": "{{baseUrl}}/workflows",
|
||||||
@ -522,7 +522,7 @@
|
|||||||
"formdata": [
|
"formdata": [
|
||||||
{
|
{
|
||||||
"key": "payload",
|
"key": "payload",
|
||||||
"value": "{\"templateType\":\"CUSTOM\",\"title\":\"Purchase Order Approval with Documents\",\"description\":\"Approval needed for office equipment purchase with supporting documents\",\"priority\":\"STANDARD\",\"approvalLevels\":[{\"email\":\"manager@{{API_DOMAIN}}\",\"tatHours\":24},{\"email\":\"director@{{API_DOMAIN}}\",\"tatHours\":48}],\"spectators\":[{\"email\":\"hr@{{API_DOMAIN}}\"}]}",
|
"value": "{\"templateType\":\"CUSTOM\",\"title\":\"Purchase Order Approval with Documents\",\"description\":\"Approval needed for office equipment purchase with supporting documents\",\"priority\":\"STANDARD\",\"approvalLevels\":[{\"email\":\"manager@{{APP_DOMAIN}}\",\"tatHours\":24},{\"email\":\"director@{{APP_DOMAIN}}\",\"tatHours\":48}],\"spectators\":[{\"email\":\"hr@{{APP_DOMAIN}}\"}]}",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"description": "JSON payload with simplified format (email + tatHours only)"
|
"description": "JSON payload with simplified format (email + tatHours only)"
|
||||||
},
|
},
|
||||||
@ -719,7 +719,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"email\": \"newapprover@{{API_DOMAIN}}\",\n \"tatHours\": 24,\n \"level\": 2\n}"
|
"raw": "{\n \"email\": \"newapprover@{{APP_DOMAIN}}\",\n \"tatHours\": 24,\n \"level\": 2\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/workflows/:id/approvers/at-level",
|
"raw": "{{baseUrl}}/workflows/:id/approvers/at-level",
|
||||||
@ -755,7 +755,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"email\": \"spectator@{{API_DOMAIN}}\"\n}"
|
"raw": "{\n \"email\": \"spectator@{{APP_DOMAIN}}\"\n}"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/workflows/:id/participants/spectator",
|
"raw": "{{baseUrl}}/workflows/:id/participants/spectator",
|
||||||
@ -3038,4 +3038,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,2 +1 @@
|
|||||||
import{a as s}from"./index-y_ojbF9T.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DfwWW08H.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
import{a as s}from"./index-5rjlVIR5.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BFJfF1vG.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||||
//# sourceMappingURL=conclusionApi-DoX_H3Tk.js.map
|
|
||||||
@ -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"}
|
|
||||||
60
build/assets/index-5rjlVIR5.js
Normal file
60
build/assets/index-5rjlVIR5.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
2
build/assets/ui-vendor-BFJfF1vG.js
Normal file
2
build/assets/ui-vendor-BFJfF1vG.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -10,14 +10,14 @@
|
|||||||
<meta name="theme-color" content="#2d4a3e" />
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<!-- Preload critical fonts and icons -->
|
<!-- Preload essential fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<script type="module" crossorigin src="/assets/index-y_ojbF9T.js"></script>
|
<script type="module" crossorigin src="/assets/index-5rjlVIR5.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DfwWW08H.js">
|
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BFJfF1vG.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
|
||||||
|
|||||||
4
build/robots.txt
Normal file
4
build/robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
|
|
||||||
|
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
||||||
9
build/sitemap.xml
Normal file
9
build/sitemap.xml
Normal 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>
|
||||||
@ -34,7 +34,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
|||||||
- **Approver Type**: System (Auto-processed)
|
- **Approver Type**: System (Auto-processed)
|
||||||
- **Action Type**: **AUTO** (System automatically creates activity)
|
- **Action Type**: **AUTO** (System automatically creates activity)
|
||||||
- **TAT**: 1 hour
|
- **TAT**: 1 hour
|
||||||
- **Mapping**: System user (`system@{{API_DOMAIN}}`)
|
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
||||||
- **Status**: Auto-approved when triggered
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
### Step 5: Dealer Completion Documents
|
### Step 5: Dealer Completion Documents
|
||||||
@ -55,7 +55,7 @@ The Claim Management workflow has **8 fixed steps** with specific approvers and
|
|||||||
- **Approver Type**: System (Auto-processed via DMS)
|
- **Approver Type**: System (Auto-processed via DMS)
|
||||||
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
- **Action Type**: **AUTO** (System generates e-invoice via DMS integration)
|
||||||
- **TAT**: 1 hour
|
- **TAT**: 1 hour
|
||||||
- **Mapping**: System user (`system@{{API_DOMAIN}}`)
|
- **Mapping**: System user (`system@{{APP_DOMAIN}}`)
|
||||||
- **Status**: Auto-approved when triggered
|
- **Status**: Auto-approved when triggered
|
||||||
|
|
||||||
### Step 8: Credit Note Confirmation
|
### Step 8: Credit Note Confirmation
|
||||||
@ -121,7 +121,7 @@ const dealerUser = await User.findOne({ where: { email: dealerEmail } });
|
|||||||
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
1. Find user with department containing "Finance" and role = 'MANAGEMENT'
|
||||||
2. Find user with designation containing "Finance" or "Accountant"
|
2. Find user with designation containing "Finance" or "Accountant"
|
||||||
3. Use configured finance team email from admin_configurations table
|
3. Use configured finance team email from admin_configurations table
|
||||||
4. Fallback: Use default finance email (e.g., finance@{{API_DOMAIN}})
|
4. Fallback: Use default finance email (e.g., finance@{{APP_DOMAIN}})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|||||||
@ -112,7 +112,7 @@ Your CSV file must have these **44 columns** in the following order:
|
|||||||
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
|
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
|
||||||
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
|
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
|
||||||
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
|
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
|
||||||
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{API_DOMAIN}}) |
|
| `domain_id` | String(255) | No | Email domain (e.g., dealer@{{APP_DOMAIN}}) |
|
||||||
| `replacement` | String(50) | No | Replacement status |
|
| `replacement` | String(50) | No | Replacement status |
|
||||||
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
|
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
|
||||||
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
|
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
|
||||||
@ -183,7 +183,7 @@ Ensure dates are in `YYYY-MM-DD` format:
|
|||||||
|
|
||||||
```csv
|
```csv
|
||||||
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
|
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
|
||||||
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{API_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.{{APP_DOMAIN}},,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist – Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
|
||||||
```
|
```
|
||||||
|
|
||||||
**What gets auto-generated:**
|
**What gets auto-generated:**
|
||||||
|
|||||||
@ -56,7 +56,7 @@ users {
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"userId": "uuid-1",
|
"userId": "uuid-1",
|
||||||
"email": "john.doe@{{API_DOMAIN}}",
|
"email": "john.doe@{{APP_DOMAIN}}",
|
||||||
"employeeId": "E12345", // Regular employee ID
|
"employeeId": "E12345", // Regular employee ID
|
||||||
"designation": "Software Engineer",
|
"designation": "Software Engineer",
|
||||||
"department": "IT",
|
"department": "IT",
|
||||||
@ -68,7 +68,7 @@ users {
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"userId": "uuid-2",
|
"userId": "uuid-2",
|
||||||
"email": "test.2@{{API_DOMAIN}}",
|
"email": "test.2@{{APP_DOMAIN}}",
|
||||||
"employeeId": "RE-MH-001", // Dealer code stored here
|
"employeeId": "RE-MH-001", // Dealer code stored here
|
||||||
"designation": "Dealer",
|
"designation": "Dealer",
|
||||||
"department": "Dealer Operations",
|
"department": "Dealer Operations",
|
||||||
|
|||||||
@ -98,8 +98,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
|||||||
|
|
||||||
**Base URL Examples:**
|
**Base URL Examples:**
|
||||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
|
- Development: `http://localhost:5000/api/v1/webhooks/dms/invoice`
|
||||||
- UAT: `https://reflow-uat.{{API_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
||||||
- Production: `https://reflow.{{API_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice`
|
||||||
|
|
||||||
### 3.2 Request Headers
|
### 3.2 Request Headers
|
||||||
|
|
||||||
@ -205,8 +205,8 @@ User-Agent: DMS-Webhook-Client/1.0
|
|||||||
|
|
||||||
**Base URL Examples:**
|
**Base URL Examples:**
|
||||||
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
|
- Development: `http://localhost:5000/api/v1/webhooks/dms/credit-note`
|
||||||
- UAT: `https://reflow-uat.{{API_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
- UAT: `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
||||||
- Production: `https://reflow.{{API_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
- Production: `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note`
|
||||||
|
|
||||||
### 4.2 Request Headers
|
### 4.2 Request Headers
|
||||||
|
|
||||||
@ -563,8 +563,8 @@ DMS_WEBHOOK_SECRET=your_shared_secret_key_here
|
|||||||
| Environment | Invoice Webhook URL | Credit Note Webhook URL |
|
| Environment | Invoice Webhook URL | Credit Note Webhook URL |
|
||||||
|-------------|---------------------|-------------------------|
|
|-------------|---------------------|-------------------------|
|
||||||
| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` |
|
| Development | `http://localhost:5000/api/v1/webhooks/dms/invoice` | `http://localhost:5000/api/v1/webhooks/dms/credit-note` |
|
||||||
| UAT | `https://reflow-uat.{{API_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow-uat.{{API_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
| UAT | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow-uat.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
||||||
| Production | `https://reflow.{{API_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow.{{API_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
| Production | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/invoice` | `https://reflow.{{APP_DOMAIN}}/api/v1/webhooks/dms/credit-note` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -157,7 +157,7 @@ npm run seed:config
|
|||||||
```bash
|
```bash
|
||||||
# Edit the script
|
# Edit the script
|
||||||
nano scripts/assign-admin-user.sql
|
nano scripts/assign-admin-user.sql
|
||||||
# Change: YOUR_EMAIL@{{API_DOMAIN}}
|
# Change: YOUR_EMAIL@{{APP_DOMAIN}}
|
||||||
|
|
||||||
# Run it
|
# Run it
|
||||||
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
|
psql -d royal_enfield_workflow -f scripts/assign-admin-user.sql
|
||||||
@ -170,7 +170,7 @@ psql -d royal_enfield_workflow
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'your-email@{{API_DOMAIN}}';
|
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Verify
|
-- Verify
|
||||||
SELECT email, role FROM users WHERE role = 'ADMIN';
|
SELECT email, role FROM users WHERE role = 'ADMIN';
|
||||||
@ -188,7 +188,7 @@ psql -d royal_enfield_workflow -c "\dt"
|
|||||||
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
|
psql -d royal_enfield_workflow -c "\dT+ user_role_enum"
|
||||||
|
|
||||||
# Check your user
|
# Check your user
|
||||||
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{API_DOMAIN}}';"
|
psql -d royal_enfield_workflow -c "SELECT email, role FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -241,13 +241,13 @@ Expected output:
|
|||||||
```sql
|
```sql
|
||||||
-- Single user
|
-- Single user
|
||||||
UPDATE users SET role = 'MANAGEMENT'
|
UPDATE users SET role = 'MANAGEMENT'
|
||||||
WHERE email = 'manager@{{API_DOMAIN}}';
|
WHERE email = 'manager@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Multiple users
|
-- Multiple users
|
||||||
UPDATE users SET role = 'MANAGEMENT'
|
UPDATE users SET role = 'MANAGEMENT'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'manager1@{{API_DOMAIN}}',
|
'manager1@{{APP_DOMAIN}}',
|
||||||
'manager2@{{API_DOMAIN}}'
|
'manager2@{{APP_DOMAIN}}'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- By department
|
-- By department
|
||||||
@ -260,13 +260,13 @@ WHERE department = 'Management' AND is_active = true;
|
|||||||
```sql
|
```sql
|
||||||
-- Single user
|
-- Single user
|
||||||
UPDATE users SET role = 'ADMIN'
|
UPDATE users SET role = 'ADMIN'
|
||||||
WHERE email = 'admin@{{API_DOMAIN}}';
|
WHERE email = 'admin@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Multiple admins
|
-- Multiple admins
|
||||||
UPDATE users SET role = 'ADMIN'
|
UPDATE users SET role = 'ADMIN'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'admin1@{{API_DOMAIN}}',
|
'admin1@{{APP_DOMAIN}}',
|
||||||
'admin2@{{API_DOMAIN}}'
|
'admin2@{{APP_DOMAIN}}'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- By department
|
-- By department
|
||||||
@ -331,7 +331,7 @@ SELECT
|
|||||||
mobile_phone,
|
mobile_phone,
|
||||||
array_length(ad_groups, 1) as ad_group_count
|
array_length(ad_groups, 1) as ad_group_count
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{API_DOMAIN}}';
|
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -344,7 +344,7 @@ WHERE email = 'your-email@{{API_DOMAIN}}';
|
|||||||
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"email": "test@{{API_DOMAIN}}",
|
"email": "test@{{APP_DOMAIN}}",
|
||||||
"displayName": "Test User",
|
"displayName": "Test User",
|
||||||
"oktaSub": "test-sub-123"
|
"oktaSub": "test-sub-123"
|
||||||
}'
|
}'
|
||||||
@ -353,14 +353,14 @@ curl -X POST http://localhost:5000/api/v1/auth/okta/callback \
|
|||||||
### 2. Check User Created with Default Role
|
### 2. Check User Created with Default Role
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT email, role FROM users WHERE email = 'test@{{API_DOMAIN}}';
|
SELECT email, role FROM users WHERE email = 'test@{{APP_DOMAIN}}';
|
||||||
-- Expected: role = 'USER'
|
-- Expected: role = 'USER'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Update to ADMIN
|
### 3. Update to ADMIN
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{API_DOMAIN}}';
|
UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{APP_DOMAIN}}';
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Verify API Access
|
### 4. Verify API Access
|
||||||
@ -369,7 +369,7 @@ UPDATE users SET role = 'ADMIN' WHERE email = 'test@{{API_DOMAIN}}';
|
|||||||
# Login and get token
|
# Login and get token
|
||||||
curl -X POST http://localhost:5000/api/v1/auth/login \
|
curl -X POST http://localhost:5000/api/v1/auth/login \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email": "test@{{API_DOMAIN}}", ...}'
|
-d '{"email": "test@{{APP_DOMAIN}}", ...}'
|
||||||
|
|
||||||
# Try admin endpoint (should work if ADMIN role)
|
# Try admin endpoint (should work if ADMIN role)
|
||||||
curl http://localhost:5000/api/v1/admin/configurations \
|
curl http://localhost:5000/api/v1/admin/configurations \
|
||||||
@ -449,7 +449,7 @@ npm run migrate
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Check if user exists
|
-- Check if user exists
|
||||||
SELECT * FROM users WHERE email = 'your-email@{{API_DOMAIN}}';
|
SELECT * FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Check Okta sub
|
-- Check Okta sub
|
||||||
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
||||||
@ -459,7 +459,7 @@ SELECT * FROM users WHERE okta_sub = 'your-okta-sub';
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Verify role
|
-- Verify role
|
||||||
SELECT email, role, is_active FROM users WHERE email = 'your-email@{{API_DOMAIN}}';
|
SELECT email, role, is_active FROM users WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Check role enum
|
-- Check role enum
|
||||||
\dT+ user_role_enum
|
\dT+ user_role_enum
|
||||||
|
|||||||
@ -29,7 +29,7 @@ This guide provides step-by-step instructions for setting up Google Cloud Storag
|
|||||||
|------|------------------|
|
|------|------------------|
|
||||||
| **Application** | Royal Enfield Workflow System |
|
| **Application** | Royal Enfield Workflow System |
|
||||||
| **Environment** | Production |
|
| **Environment** | Production |
|
||||||
| **Domain** | `https://reflow.{{API_DOMAIN}}` |
|
| **Domain** | `https://reflow.{{APP_DOMAIN}}` |
|
||||||
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
|
| **Purpose** | Store workflow documents, attachments, invoices, and credit notes |
|
||||||
| **Storage Type** | Google Cloud Storage (GCS) |
|
| **Storage Type** | Google Cloud Storage (GCS) |
|
||||||
| **Region** | `asia-south1` (Mumbai) |
|
| **Region** | `asia-south1` (Mumbai) |
|
||||||
@ -325,8 +325,8 @@ Create `cors-config-prod.json`:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"origin": [
|
"origin": [
|
||||||
"https://reflow.{{API_DOMAIN}}",
|
"https://reflow.{{APP_DOMAIN}}",
|
||||||
"https://www.{{API_DOMAIN}}"
|
"https://www.{{APP_DOMAIN}}"
|
||||||
],
|
],
|
||||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||||
"responseHeader": [
|
"responseHeader": [
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|------|-------|
|
|------|-------|
|
||||||
| **Application** | RE Workflow System |
|
| **Application** | RE Workflow System |
|
||||||
| **Environment** | UAT |
|
| **Environment** | UAT |
|
||||||
| **Domain** | https://reflow-uat.{{API_DOMAIN}} |
|
| **Domain** | https://reflow-uat.{{APP_DOMAIN}} |
|
||||||
| **Purpose** | Store workflow documents and attachments |
|
| **Purpose** | Store workflow documents and attachments |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -131,8 +131,8 @@ Apply this CORS policy to allow browser uploads:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"origin": [
|
"origin": [
|
||||||
"https://reflow-uat.{{API_DOMAIN}}",
|
"https://reflow-uat.{{APP_DOMAIN}}",
|
||||||
"https://reflow.{{API_DOMAIN}}"
|
"https://reflow.{{APP_DOMAIN}}"
|
||||||
],
|
],
|
||||||
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
"method": ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"],
|
||||||
"responseHeader": [
|
"responseHeader": [
|
||||||
|
|||||||
@ -72,8 +72,8 @@ The Users API returns a complete user object:
|
|||||||
"employeeID": "E09994",
|
"employeeID": "E09994",
|
||||||
"title": "Supports Business Applications (SAP) portfolio",
|
"title": "Supports Business Applications (SAP) portfolio",
|
||||||
"department": "Deputy Manager - Digital & IT",
|
"department": "Deputy Manager - Digital & IT",
|
||||||
"login": "sanjaysahu@{{API_DOMAIN}}",
|
"login": "sanjaysahu@{{APP_DOMAIN}}",
|
||||||
"email": "sanjaysahu@{{API_DOMAIN}}"
|
"email": "sanjaysahu@{{APP_DOMAIN}}"
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|||||||
@ -450,16 +450,16 @@ Before Migration:
|
|||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
| email | is_admin |
|
| email | is_admin |
|
||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
| admin@{{API_DOMAIN}} | true |
|
| admin@{{APP_DOMAIN}} | true |
|
||||||
| user1@{{API_DOMAIN}} | false |
|
| user1@{{APP_DOMAIN}} | false |
|
||||||
+-------------------------+-----------+
|
+-------------------------+-----------+
|
||||||
|
|
||||||
After Migration:
|
After Migration:
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
| email | role | is_admin |
|
| email | role | is_admin |
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
| admin@{{API_DOMAIN}} | ADMIN | true |
|
| admin@{{APP_DOMAIN}} | ADMIN | true |
|
||||||
| user1@{{API_DOMAIN}} | USER | false |
|
| user1@{{APP_DOMAIN}} | USER | false |
|
||||||
+-------------------------+-----------+-----------+
|
+-------------------------+-----------+-----------+
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -473,17 +473,17 @@ After Migration:
|
|||||||
-- Make user a MANAGEMENT role
|
-- Make user a MANAGEMENT role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT', is_admin = false
|
SET role = 'MANAGEMENT', is_admin = false
|
||||||
WHERE email = 'manager@{{API_DOMAIN}}';
|
WHERE email = 'manager@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Make user an ADMIN role
|
-- Make user an ADMIN role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN', is_admin = true
|
SET role = 'ADMIN', is_admin = true
|
||||||
WHERE email = 'admin@{{API_DOMAIN}}';
|
WHERE email = 'admin@{{APP_DOMAIN}}';
|
||||||
|
|
||||||
-- Revert to USER role
|
-- Revert to USER role
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'USER', is_admin = false
|
SET role = 'USER', is_admin = false
|
||||||
WHERE email = 'user@{{API_DOMAIN}}';
|
WHERE email = 'user@{{APP_DOMAIN}}';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Via API (Admin Endpoint)
|
### Via API (Admin Endpoint)
|
||||||
|
|||||||
@ -47,12 +47,12 @@ psql -d royal_enfield_db -f scripts/assign-user-roles.sql
|
|||||||
-- Make specific users ADMIN
|
-- Make specific users ADMIN
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN', is_admin = true
|
SET role = 'ADMIN', is_admin = true
|
||||||
WHERE email IN ('admin@{{API_DOMAIN}}', 'it.admin@{{API_DOMAIN}}');
|
WHERE email IN ('admin@{{APP_DOMAIN}}', 'it.admin@{{APP_DOMAIN}}');
|
||||||
|
|
||||||
-- Make specific users MANAGEMENT
|
-- Make specific users MANAGEMENT
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT', is_admin = false
|
SET role = 'MANAGEMENT', is_admin = false
|
||||||
WHERE email IN ('manager@{{API_DOMAIN}}', 'auditor@{{API_DOMAIN}}');
|
WHERE email IN ('manager@{{APP_DOMAIN}}', 'auditor@{{APP_DOMAIN}}');
|
||||||
|
|
||||||
-- Verify roles
|
-- Verify roles
|
||||||
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
|
SELECT email, display_name, role, is_admin FROM users ORDER BY role, email;
|
||||||
@ -219,7 +219,7 @@ GROUP BY role;
|
|||||||
-- Check specific user
|
-- Check specific user
|
||||||
SELECT email, role, is_admin
|
SELECT email, role, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{API_DOMAIN}}';
|
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test 2: Test API Access
|
### Test 2: Test API Access
|
||||||
@ -356,7 +356,7 @@ WHERE designation ILIKE '%manager%' OR designation ILIKE '%head%';
|
|||||||
```sql
|
```sql
|
||||||
SELECT email, role, is_admin
|
SELECT email, role, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = 'your-email@{{API_DOMAIN}}';
|
WHERE email = 'your-email@{{APP_DOMAIN}}';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -650,7 +650,7 @@ graph LR
|
|||||||
{
|
{
|
||||||
"userId": "uuid",
|
"userId": "uuid",
|
||||||
"employeeId": "EMP001",
|
"employeeId": "EMP001",
|
||||||
"email": "user@{{API_DOMAIN}}",
|
"email": "user@{{APP_DOMAIN}}",
|
||||||
"role": "USER" | "MANAGEMENT" | "ADMIN",
|
"role": "USER" | "MANAGEMENT" | "ADMIN",
|
||||||
"iat": 1234567890,
|
"iat": 1234567890,
|
||||||
"exp": 1234654290
|
"exp": 1234654290
|
||||||
|
|||||||
@ -64,7 +64,7 @@ await this.createClaimApprovalLevels(
|
|||||||
isAuto: false,
|
isAuto: false,
|
||||||
approverType: 'department_lead' as const,
|
approverType: 'department_lead' as const,
|
||||||
approverId: departmentLead?.userId || null,
|
approverId: departmentLead?.userId || null,
|
||||||
approverEmail: departmentLead?.email || initiator.manager || 'deptlead@{{API_DOMAIN}}',
|
approverEmail: departmentLead?.email || initiator.manager || `deptlead@${appDomain}`,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -181,7 +181,7 @@ POST http://localhost:5000/api/v1/auth/login
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"username": "john.doe@{{API_DOMAIN}}",
|
"username": "john.doe@{{APP_DOMAIN}}",
|
||||||
"password": "SecurePassword123!"
|
"password": "SecurePassword123!"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -41,9 +41,9 @@ USE_GOOGLE_SECRET_MANAGER=false
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{API_DOMAIN}}
|
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||||
SMTP_PASSWORD=your_smtp_password
|
SMTP_PASSWORD=your_smtp_password
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||||
|
|
||||||
# AI Service (for conclusion generation) - Vertex AI Gemini
|
# AI Service (for conclusion generation) - Vertex AI Gemini
|
||||||
# Uses service account credentials from GCP_KEY_FILE
|
# Uses service account credentials from GCP_KEY_FILE
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email = 'YOUR_EMAIL@{{API_DOMAIN}}' -- ← CHANGE THIS
|
WHERE email = 'YOUR_EMAIL@{{APP_DOMAIN}}' -- ← CHANGE THIS
|
||||||
RETURNING
|
RETURNING
|
||||||
user_id,
|
user_id,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -21,9 +21,9 @@
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'ADMIN'
|
SET role = 'ADMIN'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'admin@{{API_DOMAIN}}',
|
'admin@{{APP_DOMAIN}}',
|
||||||
'it.admin@{{API_DOMAIN}}',
|
'it.admin@{{APP_DOMAIN}}',
|
||||||
'system.admin@{{API_DOMAIN}}'
|
'system.admin@{{APP_DOMAIN}}'
|
||||||
-- Add more admin emails here
|
-- Add more admin emails here
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -45,9 +45,9 @@ ORDER BY email;
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET role = 'MANAGEMENT'
|
SET role = 'MANAGEMENT'
|
||||||
WHERE email IN (
|
WHERE email IN (
|
||||||
'manager1@{{API_DOMAIN}}',
|
'manager1@{{APP_DOMAIN}}',
|
||||||
'dept.head@{{API_DOMAIN}}',
|
'dept.head@{{APP_DOMAIN}}',
|
||||||
'auditor@{{API_DOMAIN}}'
|
'auditor@{{APP_DOMAIN}}'
|
||||||
-- Add more management emails here
|
-- Add more management emails here
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -162,7 +162,7 @@ SMTP_PORT=587
|
|||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=${SMTP_USER}
|
SMTP_USER=${SMTP_USER}
|
||||||
SMTP_PASSWORD=${SMTP_PASSWORD}
|
SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||||
|
|
||||||
# Vertex AI Gemini Configuration (for conclusion generation)
|
# Vertex AI Gemini Configuration (for conclusion generation)
|
||||||
# Service account credentials should be placed in ./credentials/ folder
|
# Service account credentials should be placed in ./credentials/ folder
|
||||||
@ -232,7 +232,7 @@ show_vapid_instructions() {
|
|||||||
echo " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
|
echo " VITE_PUBLIC_VAPID_KEY=<your-public-key>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "5. The VAPID_CONTACT should be a valid mailto: URL"
|
echo "5. The VAPID_CONTACT should be a valid mailto: URL"
|
||||||
echo " Example: mailto:admin@{{API_DOMAIN}}"
|
echo " Example: mailto:admin@{{APP_DOMAIN}}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
|
echo "Note: Keep your VAPID_PRIVATE_KEY secure and never commit it to version control!"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -38,8 +38,10 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
|
|||||||
connectSrc.push(...origins);
|
connectSrc.push(...origins);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
// Define strict CSP directives
|
// Define strict CSP directives
|
||||||
// CRITICAL: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
//: Move frame-ancestors, form-action, and base-uri to the front to ensure VAPT compliance
|
||||||
// even if the header is truncated in certain response types (like 301 redirects).
|
// even if the header is truncated in certain response types (like 301 redirects).
|
||||||
const directives = [
|
const directives = [
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
@ -53,7 +55,7 @@ app.use((req: express.Request, res: express.Response, next: express.NextFunction
|
|||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"script-src-elem 'self'",
|
"script-src-elem 'self'",
|
||||||
"script-src-attr 'none'",
|
"script-src-attr 'none'",
|
||||||
"img-src 'self' data: blob: https://*.{{API_DOMAIN}} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com",
|
`img-src 'self' data: blob: https://*.${apiDomain} https://*.okta.com https://*.oktapreview.com https://*.googleapis.com https://*.gstatic.com`,
|
||||||
"frame-src 'self' blob: data:",
|
"frame-src 'self' blob: data:",
|
||||||
"font-src 'self' https://fonts.gstatic.com data:",
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const emailConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
from: process.env.EMAIL_FROM || 'RE Workflow System <notifications@{{API_DOMAIN}}>',
|
from: process.env.EMAIL_FROM || `RE Workflow System <notifications@${process.env.APP_DOMAIN || 'royalenfield.com'}>`,
|
||||||
|
|
||||||
// Email templates
|
// Email templates
|
||||||
templates: {
|
templates: {
|
||||||
|
|||||||
@ -12,14 +12,14 @@ const ssoConfig: SSOConfig = {
|
|||||||
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||||
},
|
},
|
||||||
// Okta/Auth0 configuration for token exchange
|
// Okta/Auth0 configuration for token exchange
|
||||||
get oktaDomain() { return process.env.OKTA_DOMAIN || '{{IDP_DOMAIN}}'; },
|
get oktaDomain() { return process.env.OKTA_DOMAIN || `{{IDP_DOMAIN}}`; },
|
||||||
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
||||||
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
||||||
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
|
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API
|
||||||
// Tanflow configuration for token exchange
|
// Tanflow configuration for token exchange
|
||||||
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || '{{IDP_DOMAIN}}/realms/RE'; },
|
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || `{{IDP_DOMAIN}}/realms/RE`; },
|
||||||
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
||||||
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || '{{TANFLOW_CLIENT_SECRET}}'; },
|
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || `{{TANFLOW_CLIENT_SECRET}}`; },
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ssoConfig };
|
export { ssoConfig };
|
||||||
|
|||||||
@ -151,7 +151,7 @@ export class AuthController {
|
|||||||
message: 'Token refreshed successfully'
|
message: 'Token refreshed successfully'
|
||||||
}, 'Token refreshed successfully');
|
}, 'Token refreshed successfully');
|
||||||
} else {
|
} else {
|
||||||
// Development: Include token for debugging
|
// Dev: Include token for debugging
|
||||||
ResponseHandler.success(res, {
|
ResponseHandler.success(res, {
|
||||||
accessToken: newAccessToken
|
accessToken: newAccessToken
|
||||||
}, 'Token refreshed successfully');
|
}, 'Token refreshed successfully');
|
||||||
@ -602,7 +602,7 @@ export class AuthController {
|
|||||||
idToken: result.oktaIdToken
|
idToken: result.oktaIdToken
|
||||||
}, 'Token exchange successful');
|
}, 'Token exchange successful');
|
||||||
} else {
|
} else {
|
||||||
// Development: Include tokens for debugging and different-port setup
|
// Dev: Include tokens for debugging and different-port setup
|
||||||
ResponseHandler.success(res, {
|
ResponseHandler.success(res, {
|
||||||
user: result.user,
|
user: result.user,
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
|
|||||||
@ -10,13 +10,37 @@ export class UserController {
|
|||||||
this.userService = new UserService();
|
this.userService = new UserService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllUsers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const users = await this.userService.getAllUsers();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
users: users.map(u => ({
|
||||||
|
userId: u.userId,
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
department: u.department,
|
||||||
|
designation: u.designation,
|
||||||
|
isActive: u.isActive,
|
||||||
|
})),
|
||||||
|
total: users.length
|
||||||
|
};
|
||||||
|
|
||||||
|
ResponseHandler.success(res, result, 'All users fetched');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch all users', { error });
|
||||||
|
ResponseHandler.error(res, 'Failed to fetch all users', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async searchUsers(req: Request, res: Response): Promise<void> {
|
async searchUsers(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const q = String(req.query.q || '').trim();
|
const q = String(req.query.q || '').trim();
|
||||||
const limit = Number(req.query.limit || 10);
|
const limit = Number(req.query.limit || 10);
|
||||||
|
const source = String(req.query.source || 'default') as 'local' | 'okta' | 'default';
|
||||||
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
|
const currentUserId = (req as any).user?.userId || (req as any).user?.id;
|
||||||
|
|
||||||
const users = await this.userService.searchUsers(q, limit, currentUserId);
|
const users = await this.userService.searchUsers(q, limit, currentUserId, source);
|
||||||
|
|
||||||
const result = users.map(u => ({
|
const result = users.map(u => ({
|
||||||
userId: (u as any).userId,
|
userId: (u as any).userId,
|
||||||
@ -69,6 +93,31 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const user = await this.userService.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
ResponseHandler.error(res, 'User not found', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseHandler.success(res, {
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
department: user.department,
|
||||||
|
isActive: user.isActive
|
||||||
|
}, 'User fetched');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch user by ID', { error });
|
||||||
|
ResponseHandler.error(res, 'Failed to fetch user by ID', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure user exists in database (create if not exists)
|
* Ensure user exists in database (create if not exists)
|
||||||
* Called when user is selected/tagged in the frontend
|
* Called when user is selected/tagged in the frontend
|
||||||
|
|||||||
@ -991,9 +991,9 @@ Add to `.env`:
|
|||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=notifications@{{API_DOMAIN}}
|
SMTP_USER=notifications@{{APP_DOMAIN}}
|
||||||
SMTP_PASSWORD=your-app-specific-password
|
SMTP_PASSWORD=your-app-specific-password
|
||||||
EMAIL_FROM=RE Flow <noreply@{{API_DOMAIN}}>
|
EMAIL_FROM=RE Flow <noreply@{{APP_DOMAIN}}>
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_ENABLED=true
|
EMAIL_ENABLED=true
|
||||||
@ -1002,10 +1002,10 @@ EMAIL_BATCH_SIZE=50
|
|||||||
EMAIL_RETRY_ATTEMPTS=3
|
EMAIL_RETRY_ATTEMPTS=3
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
BASE_URL=https://workflow.{{API_DOMAIN}}
|
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
COMPANY_WEBSITE=https://www.{{API_DOMAIN}}
|
COMPANY_WEBSITE=https://www.{{APP_DOMAIN}}
|
||||||
SUPPORT_EMAIL=support@{{API_DOMAIN}}
|
SUPPORT_EMAIL=support@{{APP_DOMAIN}}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -65,7 +65,7 @@ Each template uses color-coded gradients to indicate the scenario:
|
|||||||
All templates feature a single action button:
|
All templates feature a single action button:
|
||||||
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
||||||
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
||||||
- **Example:** `https://workflow.{{API_DOMAIN}}/request/REQ-2025-12-0013`
|
- **Example:** `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
||||||
|
|
||||||
No approval/rejection buttons in emails - all actions happen within the application.
|
No approval/rejection buttons in emails - all actions happen within the application.
|
||||||
|
|
||||||
@ -231,8 +231,8 @@ SMTP_USER=your-email@domain.com
|
|||||||
SMTP_PASSWORD=your-app-password
|
SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_FROM=RE Workflow System <notifications@{{API_DOMAIN}}>
|
EMAIL_FROM=RE Workflow System <notifications@{{APP_DOMAIN}}>
|
||||||
BASE_URL=https://workflow.{{API_DOMAIN}}
|
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -361,7 +361,7 @@ All `[ViewDetailsLink]` placeholders should be replaced with:
|
|||||||
{baseURL}/request/{requestNumber}
|
{baseURL}/request/{requestNumber}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example: `https://workflow.{{API_DOMAIN}}/request/REQ-2025-12-0013`
|
Example: `https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013`
|
||||||
|
|
||||||
### Company Name
|
### Company Name
|
||||||
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const data: RequestCreatedData = {
|
|||||||
requestTime: '02:30 PM',
|
requestTime: '02:30 PM',
|
||||||
totalApprovers: 3,
|
totalApprovers: 3,
|
||||||
expectedTAT: 48,
|
expectedTAT: 48,
|
||||||
viewDetailsLink: 'https://workflow.{{API_DOMAIN}}/request/REQ-2025-12-0013',
|
viewDetailsLink: 'https://workflow.{{APP_DOMAIN}}/request/REQ-2025-12-0013',
|
||||||
companyName: 'Royal Enfield'
|
companyName: 'Royal Enfield'
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -188,10 +188,10 @@ SMTP_USER=your-email@domain.com
|
|||||||
SMTP_PASSWORD=your-app-password
|
SMTP_PASSWORD=your-app-password
|
||||||
|
|
||||||
# Email Settings
|
# Email Settings
|
||||||
EMAIL_FROM=Royal Enfield Workflow <notifications@{{API_DOMAIN}}>
|
EMAIL_FROM=Royal Enfield Workflow <notifications@{{APP_DOMAIN}}>
|
||||||
|
|
||||||
# Application Settings
|
# Application Settings
|
||||||
BASE_URL=https://workflow.{{API_DOMAIN}}
|
BASE_URL=https://workflow.{{APP_DOMAIN}}
|
||||||
COMPANY_NAME=Royal Enfield
|
COMPANY_NAME=Royal Enfield
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -7,18 +7,20 @@
|
|||||||
|
|
||||||
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company Information
|
* Company Information
|
||||||
*/
|
*/
|
||||||
export const CompanyInfo = {
|
export const CompanyInfo = {
|
||||||
name: 'Royal Enfield',
|
name: 'Royal Enfield',
|
||||||
productName: 'RE Flow', // Product name displayed in header
|
productName: 'RE Flow', // Product name displayed in header
|
||||||
website: 'https://www.{{API_DOMAIN}}',
|
website: `https://www.${appDomain}`,
|
||||||
supportEmail: 'support@{{API_DOMAIN}}',
|
supportEmail: `support@${appDomain}`,
|
||||||
|
|
||||||
// Logo configuration for email headers
|
// Logo configuration for email headers
|
||||||
logo: {
|
logo: {
|
||||||
url: 'https://www.{{API_DOMAIN}}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp',
|
url: `https://www.${appDomain}/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp`,
|
||||||
alt: 'Royal Enfield Logo',
|
alt: 'Royal Enfield Logo',
|
||||||
width: 220, // Logo width in pixels (wider for better visibility)
|
width: 220, // Logo width in pixels (wider for better visibility)
|
||||||
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
||||||
@ -88,7 +90,7 @@ export const CustomHeaderStyles = {
|
|||||||
* Usage in email service:
|
* Usage in email service:
|
||||||
* const link = getViewDetailsLink('REQ-2025-12-0013', process.env.FRONTEND_URL);
|
* const link = getViewDetailsLink('REQ-2025-12-0013', process.env.FRONTEND_URL);
|
||||||
*
|
*
|
||||||
* Result: https://workflow.{{API_DOMAIN}}/request/REQ-2025-12-0013
|
* Result: https://workflow.royalenfield.com/request/REQ-2025-12-0013
|
||||||
*/
|
*/
|
||||||
export function getViewDetailsLink(requestNumber: string, frontendUrl: string): string {
|
export function getViewDetailsLink(requestNumber: string, frontendUrl: string): string {
|
||||||
return `${frontendUrl}/request/${requestNumber}`;
|
return `${frontendUrl}/request/${requestNumber}`;
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export async function shouldSendEmail(
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check admin-level configuration (System Config)
|
// Step 1: Check admin-level configuration (System Config)
|
||||||
const adminEmailEnabled = await isAdminEmailEnabled(emailType);
|
const adminEmailEnabled = await isAdminEmailEnabled(emailType);
|
||||||
|
|
||||||
if (!adminEmailEnabled) {
|
if (!adminEmailEnabled) {
|
||||||
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
|
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
|
||||||
return false;
|
return false;
|
||||||
@ -57,7 +57,7 @@ export async function shouldSendEmail(
|
|||||||
|
|
||||||
// Step 2: Check user-level preferences
|
// Step 2: Check user-level preferences
|
||||||
const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
|
const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
|
||||||
|
|
||||||
if (!userEmailEnabled) {
|
if (!userEmailEnabled) {
|
||||||
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
|
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
|
||||||
return false;
|
return false;
|
||||||
@ -82,28 +82,28 @@ async function isAdminEmailEnabled(emailType: EmailNotificationType): Promise<bo
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check database configuration (admin panel setting)
|
// Step 1: Check database configuration (admin panel setting)
|
||||||
const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', '');
|
const dbConfigValue = await getConfigValue('ENABLE_EMAIL_NOTIFICATIONS', '');
|
||||||
|
|
||||||
if (dbConfigValue) {
|
if (dbConfigValue) {
|
||||||
// Parse database value (it's stored as string 'true' or 'false')
|
// Parse database value (it's stored as string 'true' or 'false')
|
||||||
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
||||||
|
|
||||||
if (!dbEnabled) {
|
if (!dbEnabled) {
|
||||||
logger.info('[Email] Admin has disabled email notifications globally (from database config)');
|
logger.info('[Email] Admin has disabled email notifications globally (from database config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Email] Email notifications enabled (from database config)');
|
logger.debug('[Email] Email notifications enabled (from database config)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fall back to environment variable if database config not found
|
// Step 2: Fall back to environment variable if database config not found
|
||||||
const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
|
const envEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
|
||||||
|
|
||||||
if (!envEnabled) {
|
if (!envEnabled) {
|
||||||
logger.info('[Email] Admin has disabled email notifications globally (from environment variable)');
|
logger.info('[Email] Admin has disabled email notifications globally (from environment variable)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Email] Email notifications enabled (from environment variable)');
|
logger.debug('[Email] Email notifications enabled (from environment variable)');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -131,7 +131,7 @@ async function isUserEmailEnabled(userId: string, emailType: EmailNotificationTy
|
|||||||
|
|
||||||
// Check user's global email notification setting
|
// Check user's global email notification setting
|
||||||
const enabled = (user as any).emailNotificationsEnabled !== false;
|
const enabled = (user as any).emailNotificationsEnabled !== false;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
logger.info(`[Email] User ${userId} has disabled email notifications globally`);
|
logger.info(`[Email] User ${userId} has disabled email notifications globally`);
|
||||||
}
|
}
|
||||||
@ -154,7 +154,7 @@ export async function shouldSendInAppNotification(
|
|||||||
try {
|
try {
|
||||||
// Check admin config first (if SystemConfig model exists)
|
// Check admin config first (if SystemConfig model exists)
|
||||||
const adminEnabled = await isAdminInAppEnabled(notificationType);
|
const adminEnabled = await isAdminInAppEnabled(notificationType);
|
||||||
|
|
||||||
if (!adminEnabled) {
|
if (!adminEnabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -171,7 +171,7 @@ export async function shouldSendInAppNotification(
|
|||||||
|
|
||||||
// Check user's global in-app notification setting
|
// Check user's global in-app notification setting
|
||||||
const enabled = (user as any).inAppNotificationsEnabled !== false;
|
const enabled = (user as any).inAppNotificationsEnabled !== false;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
|
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
|
||||||
}
|
}
|
||||||
@ -191,20 +191,20 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
// Step 1: Check database configuration (admin panel setting)
|
// Step 1: Check database configuration (admin panel setting)
|
||||||
const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', '');
|
const dbConfigValue = await getConfigValue('ENABLE_IN_APP_NOTIFICATIONS', '');
|
||||||
|
|
||||||
if (dbConfigValue) {
|
if (dbConfigValue) {
|
||||||
// Parse database value (it's stored as string 'true' or 'false')
|
// Parse database value (it's stored as string 'true' or 'false')
|
||||||
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
const dbEnabled = dbConfigValue.toLowerCase() === 'true';
|
||||||
|
|
||||||
if (!dbEnabled) {
|
if (!dbEnabled) {
|
||||||
logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)');
|
logger.info('[Notification] Admin has disabled in-app notifications globally (from database config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Notification] In-app notifications enabled (from database config)');
|
logger.debug('[Notification] In-app notifications enabled (from database config)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fall back to environment variable if database config not found
|
// Step 2: Fall back to environment variable if database config not found
|
||||||
const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS;
|
const envValue = process.env.ENABLE_IN_APP_NOTIFICATIONS;
|
||||||
if (envValue !== undefined) {
|
if (envValue !== undefined) {
|
||||||
@ -216,15 +216,15 @@ async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
|
|||||||
logger.debug('[Notification] In-app notifications enabled (from environment variable)');
|
logger.debug('[Notification] In-app notifications enabled (from environment variable)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Final fallback to system config (defaults to true)
|
// Step 3: Final fallback to system config (defaults to true)
|
||||||
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
|
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
|
||||||
|
|
||||||
if (!adminInAppEnabled) {
|
if (!adminInAppEnabled) {
|
||||||
logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)');
|
logger.info('[Notification] Admin has disabled in-app notifications globally (from system config)');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[Notification] In-app notifications enabled (from system config default)');
|
logger.debug('[Notification] In-app notifications enabled (from system config default)');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -270,7 +270,7 @@ export async function shouldSendEmailWithOverride(
|
|||||||
userId: string,
|
userId: string,
|
||||||
emailType: EmailNotificationType
|
emailType: EmailNotificationType
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Critical emails always sent (override user preference)
|
// emails always sent (override user preference)
|
||||||
if (CRITICAL_EMAILS.includes(emailType)) {
|
if (CRITICAL_EMAILS.includes(emailType)) {
|
||||||
const adminEnabled = await isAdminEmailEnabled(emailType);
|
const adminEnabled = await isAdminEmailEnabled(emailType);
|
||||||
if (adminEnabled) {
|
if (adminEnabled) {
|
||||||
|
|||||||
@ -14,13 +14,13 @@ async function generatePreviews() {
|
|||||||
// Sample data
|
// Sample data
|
||||||
const initiator = {
|
const initiator = {
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
email: 'john.doe@{{API_DOMAIN}}',
|
email: 'john.doe@royalenfield.com',
|
||||||
displayName: 'John Doe'
|
displayName: 'John Doe'
|
||||||
};
|
};
|
||||||
|
|
||||||
const approver = {
|
const approver = {
|
||||||
userId: 'user-2',
|
userId: 'user-2',
|
||||||
email: 'jane.smith@{{API_DOMAIN}}',
|
email: 'jane.smith@royalenfield.com',
|
||||||
displayName: 'Jane Smith'
|
displayName: 'Jane Smith'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@ async function sendTestEmail() {
|
|||||||
// Test 1: Request Created Email
|
// Test 1: Request Created Email
|
||||||
const html1 = getRequestCreatedEmail(requestCreatedData);
|
const html1 = getRequestCreatedEmail(requestCreatedData);
|
||||||
const info1 = await transporter.sendMail({
|
const info1 = await transporter.sendMail({
|
||||||
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>',
|
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||||
to: 'initiator@example.com',
|
to: 'initiator@example.com',
|
||||||
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
||||||
html: html1
|
html: html1
|
||||||
@ -113,7 +113,7 @@ async function sendTestEmail() {
|
|||||||
// Test 2: Approval Request Email (Single)
|
// Test 2: Approval Request Email (Single)
|
||||||
const html2 = getApprovalRequestEmail(approvalRequestData);
|
const html2 = getApprovalRequestEmail(approvalRequestData);
|
||||||
const info2 = await transporter.sendMail({
|
const info2 = await transporter.sendMail({
|
||||||
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>',
|
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||||
to: 'approver@example.com',
|
to: 'approver@example.com',
|
||||||
subject: '[REQ-2025-12-0013] Approval Request - Action Required',
|
subject: '[REQ-2025-12-0013] Approval Request - Action Required',
|
||||||
html: html2
|
html: html2
|
||||||
@ -126,7 +126,7 @@ async function sendTestEmail() {
|
|||||||
// Test 3: Multi-Approver Request Email
|
// Test 3: Multi-Approver Request Email
|
||||||
const html3 = getMultiApproverRequestEmail(multiApproverData);
|
const html3 = getMultiApproverRequestEmail(multiApproverData);
|
||||||
const info3 = await transporter.sendMail({
|
const info3 = await transporter.sendMail({
|
||||||
from: '"Royal Enfield Workflow" <noreply@{{API_DOMAIN}}>',
|
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||||
to: 'approver-level2@example.com',
|
to: 'approver-level2@example.com',
|
||||||
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
|
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
|
||||||
html: html3
|
html: html3
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
import { emailNotificationService } from '../services/emailNotification.service';
|
import { emailNotificationService } from '../services/emailNotification.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate real workflow scenario
|
* Simulate real workflow scenario
|
||||||
*/
|
*/
|
||||||
@ -18,7 +20,7 @@ async function testRealScenario() {
|
|||||||
// Mock user data (simulating real database records)
|
// Mock user data (simulating real database records)
|
||||||
const user10 = {
|
const user10 = {
|
||||||
userId: 'user-10-uuid',
|
userId: 'user-10-uuid',
|
||||||
email: 'john.doe@{{API_DOMAIN}}',
|
email: `john.doe@${appDomain}`,
|
||||||
displayName: 'John Doe',
|
displayName: 'John Doe',
|
||||||
department: 'Engineering',
|
department: 'Engineering',
|
||||||
designation: 'Senior Engineer'
|
designation: 'Senior Engineer'
|
||||||
@ -26,7 +28,7 @@ async function testRealScenario() {
|
|||||||
|
|
||||||
const user12 = {
|
const user12 = {
|
||||||
userId: 'user-12-uuid',
|
userId: 'user-12-uuid',
|
||||||
email: 'jane.smith@{{API_DOMAIN}}',
|
email: `jane.smith@${appDomain}`,
|
||||||
displayName: 'Jane Smith',
|
displayName: 'Jane Smith',
|
||||||
department: 'Management',
|
department: 'Management',
|
||||||
designation: 'Engineering Manager',
|
designation: 'Engineering Manager',
|
||||||
@ -52,7 +54,7 @@ async function testRealScenario() {
|
|||||||
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<p>Please review and approve at your earliest convenience.</p>
|
<p>Please review and approve at your earliest convenience.</p>
|
||||||
<p>For questions, contact: <a href="mailto:john.doe@{{API_DOMAIN}}">john.doe@{{API_DOMAIN}}</a></p>
|
<p>For questions, contact: <a href="mailto:john.doe@${appDomain}">john.doe@${appDomain}</a></p>
|
||||||
`,
|
`,
|
||||||
requestType: 'Purchase',
|
requestType: 'Purchase',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
@ -68,21 +70,21 @@ async function testRealScenario() {
|
|||||||
{
|
{
|
||||||
levelNumber: 1,
|
levelNumber: 1,
|
||||||
approverName: 'Jane Smith',
|
approverName: 'Jane Smith',
|
||||||
approverEmail: 'jane.smith@{{API_DOMAIN}}',
|
approverEmail: `jane.smith@${appDomain}`,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
levelNumber: 2,
|
levelNumber: 2,
|
||||||
approverName: 'Michael Brown',
|
approverName: 'Michael Brown',
|
||||||
approverEmail: 'michael.brown@{{API_DOMAIN}}',
|
approverEmail: `michael.brown@${appDomain}`,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
levelNumber: 3,
|
levelNumber: 3,
|
||||||
approverName: 'Sarah Johnson',
|
approverName: 'Sarah Johnson',
|
||||||
approverEmail: 'sarah.johnson@{{API_DOMAIN}}',
|
approverEmail: `sarah.johnson@${appDomain}`,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
approvedAt: null
|
approvedAt: null
|
||||||
}
|
}
|
||||||
@ -168,7 +170,7 @@ async function testRealScenario() {
|
|||||||
approvedUser12,
|
approvedUser12,
|
||||||
user10,
|
user10,
|
||||||
false, // not final approval
|
false, // not final approval
|
||||||
{ displayName: 'Michael Brown', email: 'michael.brown@{{API_DOMAIN}}' }
|
{ displayName: 'Michael Brown', email: `michael.brown@${appDomain}` }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
@ -252,4 +254,3 @@ testRealScenario()
|
|||||||
console.error('❌ Test failed:', error);
|
console.error('❌ Test failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import cors from 'cors';
|
|||||||
const getAllowedOrigins = (): string[] | boolean => {
|
const getAllowedOrigins = (): string[] | boolean => {
|
||||||
const frontendUrl = process.env.FRONTEND_URL;
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
// In development, if FRONTEND_URL is not set, default to localhost:3000
|
// In development, if FRONTEND_URL is not set, default to localhost:3000
|
||||||
if (!frontendUrl) {
|
if (!frontendUrl) {
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
@ -15,13 +15,13 @@ const getAllowedOrigins = (): string[] | boolean => {
|
|||||||
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
console.error(' Multiple origins: FRONTEND_URL=https://app1.com,https://app2.com');
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
// Development fallback: allow localhost:3000
|
// Dev fallback: allow localhost:3000
|
||||||
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
console.warn('⚠️ WARNING: FRONTEND_URL not set. Defaulting to http://localhost:3000 for development.');
|
||||||
console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
|
console.warn(' To avoid this warning, set FRONTEND_URL=http://localhost:3000 in your .env file');
|
||||||
return ['http://localhost:3000'];
|
return ['http://localhost:3000'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If FRONTEND_URL is set to '*', allow all origins
|
// If FRONTEND_URL is set to '*', allow all origins
|
||||||
if (frontendUrl === '*') {
|
if (frontendUrl === '*') {
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
@ -29,15 +29,15 @@ const getAllowedOrigins = (): string[] | boolean => {
|
|||||||
}
|
}
|
||||||
return true; // This allows any origin
|
return true; // This allows any origin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse comma-separated URLs or use single URL
|
// Parse comma-separated URLs or use single URL
|
||||||
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
const origins = frontendUrl.split(',').map(url => url.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (origins.length === 0) {
|
if (origins.length === 0) {
|
||||||
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
|
console.error('❌ ERROR: FRONTEND_URL is set but contains no valid URLs!');
|
||||||
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
|
return isProduction ? [] : ['http://localhost:3000']; // Fallback for development
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
console.log(`✅ CORS: Allowing origins from FRONTEND_URL: ${origins.join(', ')}`);
|
||||||
return origins;
|
return origins;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import { getPublicConfigurations } from '../controllers/admin.controller';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
const userController = new UserController();
|
const userController = new UserController();
|
||||||
|
|
||||||
|
// GET /api/v1/users - Get all users
|
||||||
|
router.get('/', authenticateToken, asyncHandler(userController.getAllUsers.bind(userController)));
|
||||||
|
|
||||||
// GET /api/v1/users/search?q=<email or name>
|
// GET /api/v1/users/search?q=<email or name>
|
||||||
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
|
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
|
||||||
|
|
||||||
@ -19,6 +22,9 @@ router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigura
|
|||||||
// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists)
|
// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists)
|
||||||
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));
|
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));
|
||||||
|
|
||||||
|
// GET /api/v1/users/:userId - Get user by ID
|
||||||
|
router.get('/:userId', authenticateToken, asyncHandler(userController.getUserById.bind(userController)));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -298,6 +298,7 @@ async function autoSetup(): Promise<void> {
|
|||||||
// Step 0: Initialize secrets
|
// Step 0: Initialize secrets
|
||||||
console.log('🔐 Initializing secrets...');
|
console.log('🔐 Initializing secrets...');
|
||||||
await initializeGoogleSecretManager();
|
await initializeGoogleSecretManager();
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
// Step 1: Check and create database if needed
|
// Step 1: Check and create database if needed
|
||||||
const wasCreated = await checkAndCreateDatabase();
|
const wasCreated = await checkAndCreateDatabase();
|
||||||
@ -323,7 +324,7 @@ async function autoSetup(): Promise<void> {
|
|||||||
console.log(' 1. Server will start automatically');
|
console.log(' 1. Server will start automatically');
|
||||||
console.log(' 2. Log in via SSO');
|
console.log(' 2. Log in via SSO');
|
||||||
console.log(' 3. Run this SQL to make yourself admin:');
|
console.log(' 3. Run this SQL to make yourself admin:');
|
||||||
console.log(` UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@{{API_DOMAIN}}';\n`);
|
console.log(` UPDATE users SET role = 'ADMIN' WHERE email = 'your-email@${appDomain}';\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { Dealer } from '../models/Dealer';
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
interface DealerSeedData {
|
interface DealerSeedData {
|
||||||
salesCode?: string | null;
|
salesCode?: string | null;
|
||||||
serviceCode?: string | null;
|
serviceCode?: string | null;
|
||||||
@ -77,7 +79,7 @@ const dealersData: DealerSeedData[] = [
|
|||||||
onBoardingCharges: null,
|
onBoardingCharges: null,
|
||||||
date: '2014-09-30',
|
date: '2014-09-30',
|
||||||
singleFormatMonthYear: 'Sep-2014',
|
singleFormatMonthYear: 'Sep-2014',
|
||||||
domainId: 'acceleratemotors.rrnagar@dealer.{{API_DOMAIN}}',
|
domainId: `acceleratemotors.rrnagar@dealer.${appDomain}`,
|
||||||
replacement: null,
|
replacement: null,
|
||||||
terminationResignationStatus: null,
|
terminationResignationStatus: null,
|
||||||
dateOfTerminationResignation: null,
|
dateOfTerminationResignation: null,
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { sequelize } from '../config/database';
|
|||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
interface DealerData {
|
interface DealerData {
|
||||||
email: string;
|
email: string;
|
||||||
dealerCode: string;
|
dealerCode: string;
|
||||||
@ -21,7 +23,7 @@ interface DealerData {
|
|||||||
|
|
||||||
const dealers: DealerData[] = [
|
const dealers: DealerData[] = [
|
||||||
{
|
{
|
||||||
email: 'test.2@{{API_DOMAIN}}',
|
email: `test.2@${appDomain}`,
|
||||||
dealerCode: 'RE-MH-001',
|
dealerCode: 'RE-MH-001',
|
||||||
dealerName: 'Royal Motors Mumbai',
|
dealerName: 'Royal Motors Mumbai',
|
||||||
displayName: 'Royal Motors Mumbai',
|
displayName: 'Royal Motors Mumbai',
|
||||||
@ -31,7 +33,7 @@ const dealers: DealerData[] = [
|
|||||||
role: 'USER',
|
role: 'USER',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'test.4@{{API_DOMAIN}}',
|
email: `test.4@${appDomain}`,
|
||||||
dealerCode: 'RE-DL-002',
|
dealerCode: 'RE-DL-002',
|
||||||
dealerName: 'Delhi enfield center',
|
dealerName: 'Delhi enfield center',
|
||||||
displayName: 'Delhi Enfield Center',
|
displayName: 'Delhi Enfield Center',
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { notificationService } from './notification.service';
|
|||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
import { tatSchedulerService } from './tatScheduler.service';
|
import { tatSchedulerService } from './tatScheduler.service';
|
||||||
import { emitToRequestRoom } from '../realtime/socket';
|
import { emitToRequestRoom } from '../realtime/socket';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
|
// Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService
|
||||||
|
|
||||||
export class ApprovalService {
|
export class ApprovalService {
|
||||||
@ -538,19 +540,19 @@ export class ApprovalService {
|
|||||||
// Check if it's an auto-step by checking approverEmail or levelName
|
// Check if it's an auto-step by checking approverEmail or levelName
|
||||||
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
|
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
|
||||||
// These steps are processed automatically and should NOT trigger notifications
|
// These steps are processed automatically and should NOT trigger notifications
|
||||||
const isAutoStep = (nextLevel as any).approverEmail === 'system@{{API_DOMAIN}}'
|
const isAutoStep = (nextLevel as any).approverEmail === `system@${appDomain}`
|
||||||
|| (nextLevel as any).approverName === 'System Auto-Process'
|
|| (nextLevel as any).approverName === 'System Auto-Process'
|
||||||
|| (nextLevel as any).approverId === 'system';
|
|| (nextLevel as any).approverId === 'system';
|
||||||
|
|
||||||
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
||||||
// System steps are any step with system@{{API_DOMAIN}}
|
// System steps are any step with system@${appDomain}
|
||||||
// Only send notifications to real users, NOT system processes
|
// Only send notifications to real users, NOT system processes
|
||||||
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
|
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
|
||||||
// Additional checks: ensure approverEmail and approverName are not system-related
|
// Additional checks: ensure approverEmail and approverName are not system-related
|
||||||
// This prevents notifications to system accounts even if they pass other checks
|
// This prevents notifications to system accounts even if they pass other checks
|
||||||
const approverEmail = (nextLevel as any).approverEmail || '';
|
const approverEmail = (nextLevel as any).approverEmail || '';
|
||||||
const approverName = (nextLevel as any).approverName || '';
|
const approverName = (nextLevel as any).approverName || '';
|
||||||
const isSystemEmail = approverEmail.toLowerCase() === 'system@{{API_DOMAIN}}'
|
const isSystemEmail = approverEmail.toLowerCase() === `system@${appDomain}`
|
||||||
|| approverEmail.toLowerCase().includes('system');
|
|| approverEmail.toLowerCase().includes('system');
|
||||||
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|
||||||
|| approverName.toLowerCase().includes('system');
|
|| approverName.toLowerCase().includes('system');
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { dmsIntegrationService } from './dmsIntegration.service';
|
|||||||
import { findDealerLocally } from './dealer.service';
|
import { findDealerLocally } from './dealer.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
|
|
||||||
let workflowServiceInstance: any;
|
let workflowServiceInstance: any;
|
||||||
@ -171,7 +171,7 @@ export class DealerClaimService {
|
|||||||
levelNumber: a.level,
|
levelNumber: a.level,
|
||||||
levelName: levelName,
|
levelName: levelName,
|
||||||
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
||||||
approverEmail: a.email,
|
approverEmail: `system@${appDomain}`,
|
||||||
approverName: a.name || a.email,
|
approverName: a.name || a.email,
|
||||||
tatHours: tatHours,
|
tatHours: tatHours,
|
||||||
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
||||||
@ -330,7 +330,9 @@ export class DealerClaimService {
|
|||||||
let stepDef = null;
|
let stepDef = null;
|
||||||
|
|
||||||
// Check if this is a system step by email (for backwards compatibility)
|
// Check if this is a system step by email (for backwards compatibility)
|
||||||
const isSystemEmail = approver.email === 'system@{{API_DOMAIN}}' || approver.email === 'finance@{{API_DOMAIN}}';
|
const systemEmails = [`system@${appDomain}`];
|
||||||
|
const financeEmails = [`finance@${appDomain}`];
|
||||||
|
const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email);
|
||||||
|
|
||||||
if (approver.isAdditional) {
|
if (approver.isAdditional) {
|
||||||
// Additional approver - use stepName from frontend
|
// Additional approver - use stepName from frontend
|
||||||
@ -594,7 +596,7 @@ export class DealerClaimService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed)
|
// 2. Add Dealer (treated as Okta/internal user - sync from Okta if needed)
|
||||||
if (dealerEmail && dealerEmail.toLowerCase() !== 'system@{{API_DOMAIN}}') {
|
if (dealerEmail && dealerEmail.toLowerCase() !== `system@${appDomain}`) {
|
||||||
let dealerUser = await User.findOne({
|
let dealerUser = await User.findOne({
|
||||||
where: { email: dealerEmail.toLowerCase() },
|
where: { email: dealerEmail.toLowerCase() },
|
||||||
});
|
});
|
||||||
@ -626,7 +628,7 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
// 3. Add all approvers from approval levels (excluding system and duplicates)
|
// 3. Add all approvers from approval levels (excluding system and duplicates)
|
||||||
const addedUserIds = new Set<string>([initiatorId]);
|
const addedUserIds = new Set<string>([initiatorId]);
|
||||||
const systemEmails = ['system@{{API_DOMAIN}}'];
|
const systemEmails = [`system@${appDomain}`];
|
||||||
|
|
||||||
for (const level of approvalLevels) {
|
for (const level of approvalLevels) {
|
||||||
const approverEmail = (level as any).approverEmail?.toLowerCase();
|
const approverEmail = (level as any).approverEmail?.toLowerCase();
|
||||||
@ -3163,7 +3165,7 @@ export class DealerClaimService {
|
|||||||
deptLeadLevel.levelName || undefined
|
deptLeadLevel.levelName || undefined
|
||||||
);
|
);
|
||||||
} catch (snapshotError) {
|
} catch (snapshotError) {
|
||||||
// Log error but don't fail the reopen - snapshot is for audit, not critical
|
// Log error but don't fail the reopen - snapshot is for audit, not essential
|
||||||
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3286,7 +3288,7 @@ export class DealerClaimService {
|
|||||||
previousLevel.levelName || undefined
|
previousLevel.levelName || undefined
|
||||||
);
|
);
|
||||||
} catch (snapshotError) {
|
} catch (snapshotError) {
|
||||||
// Log error but don't fail the revise - snapshot is for audit, not critical
|
// Log error but don't fail the revise - snapshot is for audit, not essential
|
||||||
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import { tatSchedulerService } from './tatScheduler.service';
|
|||||||
import { DealerClaimService } from './dealerClaim.service';
|
import { DealerClaimService } from './dealerClaim.service';
|
||||||
import { emitToRequestRoom } from '../realtime/socket';
|
import { emitToRequestRoom } from '../realtime/socket';
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
|
|
||||||
let dealerClaimServiceInstance: any;
|
let dealerClaimServiceInstance: any;
|
||||||
|
|
||||||
@ -147,7 +149,7 @@ export class DealerClaimApprovalService {
|
|||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
} catch (snapshotError) {
|
} catch (snapshotError) {
|
||||||
// Log error but don't fail the approval - snapshot is for audit, not critical
|
// Log error but don't fail the approval - snapshot is for audit, not essential
|
||||||
logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError);
|
logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -399,11 +401,11 @@ export class DealerClaimApprovalService {
|
|||||||
const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver';
|
const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver';
|
||||||
|
|
||||||
// Check if it's an auto-step or system process
|
// Check if it's an auto-step or system process
|
||||||
const isAutoStep = nextApproverEmail === 'system@{{API_DOMAIN}}'
|
const isAutoStep = nextApproverEmail === `system@${appDomain}`
|
||||||
|| (nextLevel as any).approverName === 'System Auto-Process'
|
|| (nextLevel as any).approverName === 'System Auto-Process'
|
||||||
|| nextApproverId === 'system';
|
|| nextApproverId === 'system';
|
||||||
|
|
||||||
const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@{{API_DOMAIN}}'
|
const isSystemEmail = nextApproverEmail.toLowerCase() === `system@${appDomain}`
|
||||||
|| nextApproverEmail.toLowerCase().includes('system');
|
|| nextApproverEmail.toLowerCase().includes('system');
|
||||||
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|
const isSystemName = nextApproverName.toLowerCase() === 'system auto-process'
|
||||||
|| nextApproverName.toLowerCase().includes('system');
|
|| nextApproverName.toLowerCase().includes('system');
|
||||||
@ -747,7 +749,7 @@ export class DealerClaimApprovalService {
|
|||||||
level.levelName || undefined
|
level.levelName || undefined
|
||||||
);
|
);
|
||||||
} catch (snapshotError) {
|
} catch (snapshotError) {
|
||||||
// Log error but don't fail the rejection - snapshot is for audit, not critical
|
// Log error but don't fail the rejection - snapshot is for audit, not essential
|
||||||
logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -962,4 +964,3 @@ export class DealerClaimApprovalService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,11 +17,7 @@ interface EmailOptions {
|
|||||||
attachments?: any[];
|
attachments?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardcoded BCC addresses (temporary - for time being)
|
|
||||||
const HARDCODED_BCC: string[] = [
|
|
||||||
'{{USER_EMAIL}}',
|
|
||||||
// Add your BCC email addresses here
|
|
||||||
];
|
|
||||||
|
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private transporter: nodemailer.Transporter | null = null;
|
private transporter: nodemailer.Transporter | null = null;
|
||||||
@ -44,7 +40,7 @@ export class EmailService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production SMTP configuration
|
// Prod SMTP configuration
|
||||||
try {
|
try {
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
host: smtpHost,
|
host: smtpHost,
|
||||||
@ -119,24 +115,13 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
||||||
const fromAddress = process.env.EMAIL_FROM || 'RE Flow <noreply@{{API_DOMAIN}}>';
|
const fromAddress = process.env.EMAIL_FROM || `RE Flow <noreply@${process.env.APP_DOMAIN || 'royalenfield.com'}>`;
|
||||||
|
|
||||||
// Merge hardcoded BCC with provided BCC
|
|
||||||
let bccRecipients: string[] = [];
|
|
||||||
if (HARDCODED_BCC.length > 0) {
|
|
||||||
bccRecipients = [...HARDCODED_BCC];
|
|
||||||
}
|
|
||||||
if (options.bcc) {
|
|
||||||
const providedBcc = Array.isArray(options.bcc) ? options.bcc : [options.bcc];
|
|
||||||
bccRecipients = [...bccRecipients, ...providedBcc];
|
|
||||||
}
|
|
||||||
const finalBcc = bccRecipients.length > 0 ? bccRecipients : undefined;
|
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: fromAddress,
|
from: fromAddress,
|
||||||
to: recipients,
|
to: recipients,
|
||||||
cc: options.cc,
|
cc: options.cc,
|
||||||
bcc: finalBcc,
|
bcc: options.bcc || [],
|
||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
html: options.html,
|
html: options.html,
|
||||||
attachments: options.attachments
|
attachments: options.attachments
|
||||||
|
|||||||
@ -289,7 +289,7 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 4. Send Rejection Notification Email (CRITICAL)
|
* 4. Send Rejection Notification Email (ESSENTIAL)
|
||||||
*/
|
*/
|
||||||
async sendRejectionNotification(
|
async sendRejectionNotification(
|
||||||
requestData: any,
|
requestData: any,
|
||||||
@ -298,7 +298,7 @@ export class EmailNotificationService {
|
|||||||
rejectionReason: string
|
rejectionReason: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Use override for critical emails
|
// Use override for high-priority emails
|
||||||
const canSend = await shouldSendEmailWithOverride(
|
const canSend = await shouldSendEmailWithOverride(
|
||||||
initiatorData.userId,
|
initiatorData.userId,
|
||||||
EmailNotificationType.REQUEST_REJECTED
|
EmailNotificationType.REQUEST_REJECTED
|
||||||
@ -416,7 +416,7 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 6. Send TAT Breached Email (CRITICAL)
|
* 6. Send TAT Breached Email (ESSENTIAL)
|
||||||
*/
|
*/
|
||||||
async sendTATBreached(
|
async sendTATBreached(
|
||||||
requestData: any,
|
requestData: any,
|
||||||
@ -428,7 +428,7 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Use override for critical emails
|
// Use override for high-priority emails
|
||||||
const canSend = await shouldSendEmailWithOverride(
|
const canSend = await shouldSendEmailWithOverride(
|
||||||
approverData.userId,
|
approverData.userId,
|
||||||
EmailNotificationType.TAT_BREACHED
|
EmailNotificationType.TAT_BREACHED
|
||||||
@ -1086,9 +1086,9 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if next approver is the recipient (initiator reviewing their own request)
|
// Check if next approver is the recipient (initiator reviewing their own request)
|
||||||
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
|
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
|
||||||
(nextApproverData && nextApproverData.userId === recipientData.userId);
|
(nextApproverData && nextApproverData.userId === recipientData.userId);
|
||||||
|
|
||||||
const data: DealerProposalSubmittedData = {
|
const data: DealerProposalSubmittedData = {
|
||||||
recipientName: recipientData.displayName || recipientData.email,
|
recipientName: recipientData.displayName || recipientData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
@ -1102,7 +1102,7 @@ export class EmailNotificationService {
|
|||||||
costBreakupSummary: costBreakupSummary,
|
costBreakupSummary: costBreakupSummary,
|
||||||
submittedDate: this.formatDate(proposalData.submittedAt || new Date()),
|
submittedDate: this.formatDate(proposalData.submittedAt || new Date()),
|
||||||
submittedTime: this.formatTime(proposalData.submittedAt || new Date()),
|
submittedTime: this.formatTime(proposalData.submittedAt || new Date()),
|
||||||
nextApproverName: isNextApproverInitiator
|
nextApproverName: isNextApproverInitiator
|
||||||
? undefined // Don't show next approver name if it's the recipient themselves
|
? undefined // Don't show next approver name if it's the recipient themselves
|
||||||
: (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
|
: (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
@ -1218,9 +1218,9 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if next approver is the recipient (initiator reviewing their own request)
|
// Check if next approver is the recipient (initiator reviewing their own request)
|
||||||
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
|
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
|
||||||
(nextApproverData && nextApproverData.userId === recipientData.userId);
|
(nextApproverData && nextApproverData.userId === recipientData.userId);
|
||||||
|
|
||||||
const data: CompletionDocumentsSubmittedData = {
|
const data: CompletionDocumentsSubmittedData = {
|
||||||
recipientName: recipientData.displayName || recipientData.email,
|
recipientName: recipientData.displayName || recipientData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
@ -1234,7 +1234,7 @@ export class EmailNotificationService {
|
|||||||
documentsCount: completionData.documentsCount,
|
documentsCount: completionData.documentsCount,
|
||||||
submittedDate: this.formatDate(completionData.submittedAt || new Date()),
|
submittedDate: this.formatDate(completionData.submittedAt || new Date()),
|
||||||
submittedTime: this.formatTime(completionData.submittedAt || new Date()),
|
submittedTime: this.formatTime(completionData.submittedAt || new Date()),
|
||||||
nextApproverName: isNextApproverInitiator
|
nextApproverName: isNextApproverInitiator
|
||||||
? undefined // Don't show next approver name if it's the recipient themselves
|
? undefined // Don't show next approver name if it's the recipient themselves
|
||||||
: (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
|
: (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)),
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
|
|||||||
@ -23,13 +23,15 @@ interface NotificationPayload {
|
|||||||
metadata?: any;
|
metadata?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
|
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
|
||||||
|
|
||||||
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
|
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
|
||||||
const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || '';
|
const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || '';
|
||||||
const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || '';
|
const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || '';
|
||||||
const contact = mailto || process.env.VAPID_CONTACT || 'mailto:admin@example.com';
|
const contact = mailto || process.env.VAPID_CONTACT || `mailto:admin@${appDomain}`;
|
||||||
if (!pub || !priv) {
|
if (!pub || !priv) {
|
||||||
logger.warn('VAPID keys are not configured. Push notifications are disabled.');
|
logger.warn('VAPID keys are not configured. Push notifications are disabled.');
|
||||||
return;
|
return;
|
||||||
@ -319,12 +321,12 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if email should be sent (admin + user preferences)
|
// Check if email should be sent (admin + user preferences)
|
||||||
// Critical emails: rejection, tat_breach, breach
|
// emails: rejection, tat_breach, breach
|
||||||
const isCriticalEmail = payload.type === 'rejection' ||
|
const isCriticalEmail = payload.type === 'rejection' ||
|
||||||
payload.type === 'tat_breach' ||
|
payload.type === 'tat_breach' ||
|
||||||
payload.type === 'breach';
|
payload.type === 'breach';
|
||||||
const shouldSend = isCriticalEmail
|
const shouldSend = isCriticalEmail
|
||||||
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
? await shouldSendEmailWithOverride(userId, emailType) // emails
|
||||||
: payload.type === 'assignment'
|
: payload.type === 'assignment'
|
||||||
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
|
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
|
||||||
: await shouldSendEmail(userId, emailType); // Regular emails
|
: await shouldSendEmail(userId, emailType); // Regular emails
|
||||||
@ -569,7 +571,7 @@ class NotificationService {
|
|||||||
approverData = {
|
approverData = {
|
||||||
userId: (rejectedLevel as any).approverId,
|
userId: (rejectedLevel as any).approverId,
|
||||||
displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
|
displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
|
||||||
email: (rejectedLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}',
|
email: (rejectedLevel as any).approverEmail || `unknown@${appDomain}`,
|
||||||
rejectedAt: (rejectedLevel as any).actionDate,
|
rejectedAt: (rejectedLevel as any).actionDate,
|
||||||
comments: (rejectedLevel as any).comments
|
comments: (rejectedLevel as any).comments
|
||||||
};
|
};
|
||||||
@ -612,7 +614,7 @@ class NotificationService {
|
|||||||
approverData = {
|
approverData = {
|
||||||
userId: (currentLevel as any).approverId,
|
userId: (currentLevel as any).approverId,
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}'
|
email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -699,7 +701,7 @@ class NotificationService {
|
|||||||
approverData = {
|
approverData = {
|
||||||
userId: (currentLevel as any).approverId,
|
userId: (currentLevel as any).approverId,
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}'
|
email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -833,7 +835,7 @@ class NotificationService {
|
|||||||
recipientData = {
|
recipientData = {
|
||||||
userId: (pausedLevel as any).approverId,
|
userId: (pausedLevel as any).approverId,
|
||||||
displayName: (pausedLevel as any).approverName || 'Unknown Approver',
|
displayName: (pausedLevel as any).approverName || 'Unknown Approver',
|
||||||
email: (pausedLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}'
|
email: (pausedLevel as any).approverEmail || `unknown@${appDomain}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -885,7 +887,7 @@ class NotificationService {
|
|||||||
recipientData = {
|
recipientData = {
|
||||||
userId: (currentLevel as any).approverId,
|
userId: (currentLevel as any).approverId,
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown User',
|
displayName: (currentLevel as any).approverName || 'Unknown User',
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@{{API_DOMAIN}}'
|
email: (currentLevel as any).approverEmail || `unknown@${appDomain}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export class PdfService {
|
|||||||
private getInvoiceHtmlTemplate(data: any): string {
|
private getInvoiceHtmlTemplate(data: any): string {
|
||||||
const { request, invoice, dealer, claimDetails } = data;
|
const { request, invoice, dealer, claimDetails } = data;
|
||||||
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
|
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
|
||||||
const logoUrl = '{{LOGO_URL}}';
|
const logoUrl = `{{LOGO_URL}}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -119,7 +119,7 @@ export class PdfService {
|
|||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
|
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
|
||||||
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">{{BUYER_GSTIN}}</div></div>
|
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">` + `{{BUYER_GSTIN}}` + `</div></div>
|
||||||
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
|
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
|
||||||
<br/>
|
<br/>
|
||||||
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>
|
<div class="info-row"><div class="info-label">Vehicle Owner</div><div class="info-value">N/A</div></div>
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { DealerClaimDetails } from '../models/DealerClaimDetails';
|
|||||||
* PWC E-Invoice Integration Service
|
* PWC E-Invoice Integration Service
|
||||||
* Handles communication with PWC API for signed invoice generation
|
* Handles communication with PWC API for signed invoice generation
|
||||||
*/
|
*/
|
||||||
|
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
|
||||||
|
|
||||||
export class PWCIntegrationService {
|
export class PWCIntegrationService {
|
||||||
private apiUrl: string;
|
private apiUrl: string;
|
||||||
private customerId: string;
|
private customerId: string;
|
||||||
@ -123,7 +125,7 @@ export class PWCIntegrationService {
|
|||||||
SourceSystem: "RE_WORKFLOW",
|
SourceSystem: "RE_WORKFLOW",
|
||||||
is_irn: "Y",
|
is_irn: "Y",
|
||||||
is_ewb: "N",
|
is_ewb: "N",
|
||||||
email: (request as any).initiator?.email || "system@{{API_DOMAIN}}",
|
email: (request as any).initiator?.email || `system@${appDomain}`,
|
||||||
TranDtls: {
|
TranDtls: {
|
||||||
TaxSch: "GST",
|
TaxSch: "GST",
|
||||||
SubType: "SUPPLY",
|
SubType: "SUPPLY",
|
||||||
|
|||||||
@ -34,17 +34,17 @@ export class TatSchedulerService {
|
|||||||
// Handle both enum and string (case-insensitive) priority values
|
// Handle both enum and string (case-insensitive) priority values
|
||||||
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
|
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
|
||||||
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
|
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
|
||||||
|
|
||||||
// Get current thresholds from database configuration
|
// Get current thresholds from database configuration
|
||||||
const thresholds = await getTatThresholds();
|
const thresholds = await getTatThresholds();
|
||||||
|
|
||||||
// Calculate milestone times using configured thresholds
|
// Calculate milestone times using configured thresholds
|
||||||
// EXPRESS mode: 24/7 calculation (includes holidays, weekends, non-working hours)
|
// EXPRESS mode: 24/7 calculation (includes holidays, weekends, non-working hours)
|
||||||
// STANDARD mode: Working hours only (excludes holidays, weekends, non-working hours)
|
// STANDARD mode: Working hours only (excludes holidays, weekends, non-working hours)
|
||||||
let threshold1Time: Date;
|
let threshold1Time: Date;
|
||||||
let threshold2Time: Date;
|
let threshold2Time: Date;
|
||||||
let breachTime: Date;
|
let breachTime: Date;
|
||||||
|
|
||||||
if (isExpress) {
|
if (isExpress) {
|
||||||
// EXPRESS: All calendar days (Mon-Sun, including weekends/holidays) but working hours only (9 AM - 6 PM)
|
// EXPRESS: All calendar days (Mon-Sun, including weekends/holidays) but working hours only (9 AM - 6 PM)
|
||||||
const t1 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.first / 100));
|
const t1 = await addWorkingHoursExpress(now, tatDurationHours * (thresholds.first / 100));
|
||||||
@ -89,11 +89,11 @@ export class TatSchedulerService {
|
|||||||
|
|
||||||
// Check if test mode enabled (1 hour = 1 minute)
|
// Check if test mode enabled (1 hour = 1 minute)
|
||||||
const isTestMode = process.env.TAT_TEST_MODE === 'true';
|
const isTestMode = process.env.TAT_TEST_MODE === 'true';
|
||||||
|
|
||||||
// Check if times collide (working hours calculation issue)
|
// Check if times collide (working hours calculation issue)
|
||||||
const uniqueTimes = new Set(jobs.map(j => j.targetTime.getTime()));
|
const uniqueTimes = new Set(jobs.map(j => j.targetTime.getTime()));
|
||||||
const hasCollision = uniqueTimes.size < jobs.length;
|
const hasCollision = uniqueTimes.size < jobs.length;
|
||||||
|
|
||||||
let jobIndex = 0;
|
let jobIndex = 0;
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (job.delay < 0) {
|
if (job.delay < 0) {
|
||||||
@ -102,21 +102,21 @@ export class TatSchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let spacedDelay: number;
|
let spacedDelay: number;
|
||||||
|
|
||||||
if (isTestMode) {
|
if (isTestMode) {
|
||||||
// Test mode: times are already in minutes (tatTimeUtils converts hours to minutes)
|
// Test mode: times are already in minutes (tatTimeUtils converts hours to minutes)
|
||||||
// Just ensure they have minimum spacing for BullMQ reliability
|
// Just ensure they have minimum spacing for BullMQ reliability
|
||||||
spacedDelay = Math.max(job.delay, 5000) + (jobIndex * 5000);
|
spacedDelay = Math.max(job.delay, 5000) + (jobIndex * 5000);
|
||||||
} else if (hasCollision) {
|
} else if (hasCollision) {
|
||||||
// Production with collision: add 5-minute spacing
|
// Prod with collision: add 5-minute spacing
|
||||||
spacedDelay = job.delay + (jobIndex * 300000);
|
spacedDelay = job.delay + (jobIndex * 300000);
|
||||||
} else {
|
} else {
|
||||||
// Production without collision: use calculated delays
|
// Prod without collision: use calculated delays
|
||||||
spacedDelay = job.delay;
|
spacedDelay = job.delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = `tat-${job.type}-${requestId}-${levelId}`;
|
const jobId = `tat-${job.type}-${requestId}-${levelId}`;
|
||||||
|
|
||||||
await tatQueue.add(
|
await tatQueue.add(
|
||||||
job.type,
|
job.type,
|
||||||
{
|
{
|
||||||
@ -186,17 +186,17 @@ export class TatSchedulerService {
|
|||||||
// Handle both enum and string (case-insensitive) priority values
|
// Handle both enum and string (case-insensitive) priority values
|
||||||
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
|
const priorityStr = typeof priority === 'string' ? priority.toUpperCase() : priority;
|
||||||
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
|
const isExpress = priorityStr === Priority.EXPRESS || priorityStr === 'EXPRESS';
|
||||||
|
|
||||||
// Get current thresholds from database configuration
|
// Get current thresholds from database configuration
|
||||||
const thresholds = await getTatThresholds();
|
const thresholds = await getTatThresholds();
|
||||||
|
|
||||||
// Calculate original TAT from remaining + elapsed
|
// Calculate original TAT from remaining + elapsed
|
||||||
// Example: If 35 min used (58.33%) and 25 min remaining, original TAT = 60 min
|
// Example: If 35 min used (58.33%) and 25 min remaining, original TAT = 60 min
|
||||||
const elapsedHours = alertStatus.percentageUsedAtPause > 0
|
const elapsedHours = alertStatus.percentageUsedAtPause > 0
|
||||||
? (remainingTatHours * alertStatus.percentageUsedAtPause) / (100 - alertStatus.percentageUsedAtPause)
|
? (remainingTatHours * alertStatus.percentageUsedAtPause) / (100 - alertStatus.percentageUsedAtPause)
|
||||||
: 0;
|
: 0;
|
||||||
const originalTatHours = elapsedHours + remainingTatHours;
|
const originalTatHours = elapsedHours + remainingTatHours;
|
||||||
|
|
||||||
logger.info(`[TAT Scheduler] Resuming TAT scheduling - Request: ${requestId}, Remaining: ${(remainingTatHours * 60).toFixed(1)} min, Priority: ${isExpress ? 'EXPRESS' : 'STANDARD'}`);
|
logger.info(`[TAT Scheduler] Resuming TAT scheduling - Request: ${requestId}, Remaining: ${(remainingTatHours * 60).toFixed(1)} min, Priority: ${isExpress ? 'EXPRESS' : 'STANDARD'}`);
|
||||||
|
|
||||||
// Jobs to schedule - only include those that haven't been sent and haven't been passed
|
// Jobs to schedule - only include those that haven't been sent and haven't been passed
|
||||||
@ -216,7 +216,7 @@ export class TatSchedulerService {
|
|||||||
// thresholdHours = originalTatHours * (threshold/100)
|
// thresholdHours = originalTatHours * (threshold/100)
|
||||||
const thresholdHours = originalTatHours * (thresholds.first / 100);
|
const thresholdHours = originalTatHours * (thresholds.first / 100);
|
||||||
const hoursFromNow = thresholdHours - elapsedHours;
|
const hoursFromNow = thresholdHours - elapsedHours;
|
||||||
|
|
||||||
if (hoursFromNow > 0) {
|
if (hoursFromNow > 0) {
|
||||||
jobsToSchedule.push({
|
jobsToSchedule.push({
|
||||||
type: 'threshold1',
|
type: 'threshold1',
|
||||||
@ -232,7 +232,7 @@ export class TatSchedulerService {
|
|||||||
if (!alertStatus.tat75AlertSent && alertStatus.percentageUsedAtPause < thresholds.second) {
|
if (!alertStatus.tat75AlertSent && alertStatus.percentageUsedAtPause < thresholds.second) {
|
||||||
const thresholdHours = originalTatHours * (thresholds.second / 100);
|
const thresholdHours = originalTatHours * (thresholds.second / 100);
|
||||||
const hoursFromNow = thresholdHours - elapsedHours;
|
const hoursFromNow = thresholdHours - elapsedHours;
|
||||||
|
|
||||||
if (hoursFromNow > 0) {
|
if (hoursFromNow > 0) {
|
||||||
jobsToSchedule.push({
|
jobsToSchedule.push({
|
||||||
type: 'threshold2',
|
type: 'threshold2',
|
||||||
@ -264,7 +264,7 @@ export class TatSchedulerService {
|
|||||||
// Calculate actual times and schedule jobs
|
// Calculate actual times and schedule jobs
|
||||||
for (const job of jobsToSchedule) {
|
for (const job of jobsToSchedule) {
|
||||||
let targetTime: Date;
|
let targetTime: Date;
|
||||||
|
|
||||||
if (isExpress) {
|
if (isExpress) {
|
||||||
targetTime = (await addWorkingHoursExpress(now, job.hoursFromNow)).toDate();
|
targetTime = (await addWorkingHoursExpress(now, job.hoursFromNow)).toDate();
|
||||||
} else {
|
} else {
|
||||||
@ -272,14 +272,14 @@ export class TatSchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = calculateDelay(targetTime);
|
const delay = calculateDelay(targetTime);
|
||||||
|
|
||||||
if (delay < 0) {
|
if (delay < 0) {
|
||||||
logger.warn(`[TAT Scheduler] Skipping ${job.type} - calculated time is in past`);
|
logger.warn(`[TAT Scheduler] Skipping ${job.type} - calculated time is in past`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = `tat-${job.type}-${requestId}-${levelId}`;
|
const jobId = `tat-${job.type}-${requestId}-${levelId}`;
|
||||||
|
|
||||||
await tatQueue.add(
|
await tatQueue.add(
|
||||||
job.type,
|
job.type,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -239,7 +239,7 @@ export class TemplateService {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[TemplateService] Error incrementing usage count:', error);
|
logger.error('[TemplateService] Error incrementing usage count:', error);
|
||||||
// Don't throw - this is not critical
|
// Don't throw - this is not essential
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ interface OktaUser {
|
|||||||
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
|
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
|
||||||
try {
|
try {
|
||||||
const profile = oktaUserResponse.profile || {};
|
const profile = oktaUserResponse.profile || {};
|
||||||
|
|
||||||
const userData: SSOUserData = {
|
const userData: SSOUserData = {
|
||||||
oktaSub: oktaUserResponse.id || '',
|
oktaSub: oktaUserResponse.id || '',
|
||||||
email: profile.email || profile.login || '',
|
email: profile.email || profile.login || '',
|
||||||
@ -93,7 +93,7 @@ export class UserService {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
|
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!ssoData.email || !ssoData.oktaSub) {
|
if (!ssoData.email || !ssoData.oktaSub) {
|
||||||
@ -113,14 +113,14 @@ export class UserService {
|
|||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Update existing user - DO NOT update email (crucial identifier)
|
// Update existing user - DO NOT update email (crucial identifier)
|
||||||
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
|
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
|
||||||
|
|
||||||
await existingUser.update(updatePayload);
|
await existingUser.update(updatePayload);
|
||||||
|
|
||||||
return existingUser;
|
return existingUser;
|
||||||
} else {
|
} else {
|
||||||
// Create new user - oktaSub is required, email is included
|
// Create new user - oktaSub is required, email is included
|
||||||
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
|
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
|
||||||
|
|
||||||
const newUser = await UserModel.create(createPayload);
|
const newUser = await UserModel.create(createPayload);
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
@ -141,7 +141,7 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise<any[]> {
|
async searchUsers(query: string, limit: number = 10, excludeUserId?: string, source: 'local' | 'okta' | 'default' = 'default'): Promise<any[]> {
|
||||||
const q = (query || '').trim();
|
const q = (query || '').trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return [];
|
return [];
|
||||||
@ -160,6 +160,11 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If source is strictly 'local', skip Okta and search DB directly
|
||||||
|
if (source === 'local') {
|
||||||
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
||||||
|
}
|
||||||
|
|
||||||
// Search Okta users
|
// Search Okta users
|
||||||
try {
|
try {
|
||||||
const oktaDomain = process.env.OKTA_DOMAIN;
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
||||||
@ -179,20 +184,20 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const oktaUsers: OktaUser[] = response.data || [];
|
const oktaUsers: OktaUser[] = response.data || [];
|
||||||
|
|
||||||
// Transform Okta users to our format
|
// Transform Okta users to our format
|
||||||
return oktaUsers
|
return oktaUsers
|
||||||
.filter(u => {
|
.filter(u => {
|
||||||
// Filter out inactive users
|
// Filter out inactive users
|
||||||
if (u.status !== 'ACTIVE') return false;
|
if (u.status !== 'ACTIVE') return false;
|
||||||
|
|
||||||
// Filter out current user by Okta ID or email
|
// Filter out current user by Okta ID or email
|
||||||
if (excludeUserId && u.id === excludeUserId) return false;
|
if (excludeUserId && u.id === excludeUserId) return false;
|
||||||
if (excludeEmail) {
|
if (excludeEmail) {
|
||||||
const userEmail = (u.profile.email || u.profile.login || '').toLowerCase();
|
const userEmail = (u.profile.email || u.profile.login || '').toLowerCase();
|
||||||
if (userEmail === excludeEmail) return false;
|
if (userEmail === excludeEmail) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(u => ({
|
.map(u => ({
|
||||||
@ -339,9 +344,9 @@ export class UserService {
|
|||||||
|
|
||||||
// Search Okta users by displayName
|
// Search Okta users by displayName
|
||||||
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
||||||
params: {
|
params: {
|
||||||
search: `profile.displayName eq "${displayName}"`,
|
search: `profile.displayName eq "${displayName}"`,
|
||||||
limit: 50
|
limit: 50
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `SSWS ${oktaApiToken}`,
|
'Authorization': `SSWS ${oktaApiToken}`,
|
||||||
@ -351,7 +356,7 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const oktaUsers: OktaUser[] = response.data || [];
|
const oktaUsers: OktaUser[] = response.data || [];
|
||||||
|
|
||||||
// Filter only active users
|
// Filter only active users
|
||||||
return oktaUsers.filter(u => u.status === 'ACTIVE');
|
return oktaUsers.filter(u => u.status === 'ACTIVE');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -367,7 +372,7 @@ export class UserService {
|
|||||||
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
|
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
|
||||||
const userData = await this.fetchAndExtractOktaUserByEmail(email);
|
const userData = await this.fetchAndExtractOktaUserByEmail(email);
|
||||||
if (!userData) return null;
|
if (!userData) return null;
|
||||||
|
|
||||||
// Return in legacy format for backward compatibility
|
// Return in legacy format for backward compatibility
|
||||||
return {
|
return {
|
||||||
id: userData.oktaSub,
|
id: userData.oktaSub,
|
||||||
@ -408,7 +413,7 @@ export class UserService {
|
|||||||
location?: string;
|
location?: string;
|
||||||
}): Promise<UserModel> {
|
}): Promise<UserModel> {
|
||||||
const email = oktaUserData.email.toLowerCase();
|
const email = oktaUserData.email.toLowerCase();
|
||||||
|
|
||||||
// Check if user already exists in database
|
// Check if user already exists in database
|
||||||
let user = await UserModel.findOne({
|
let user = await UserModel.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -426,7 +431,7 @@ export class UserService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId;
|
if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId;
|
||||||
if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName;
|
if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName;
|
||||||
if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName;
|
if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName;
|
||||||
@ -435,7 +440,7 @@ export class UserService {
|
|||||||
if (oktaUserData.phone) updateData.phone = oktaUserData.phone;
|
if (oktaUserData.phone) updateData.phone = oktaUserData.phone;
|
||||||
if (oktaUserData.designation) updateData.designation = oktaUserData.designation;
|
if (oktaUserData.designation) updateData.designation = oktaUserData.designation;
|
||||||
if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId;
|
if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId;
|
||||||
|
|
||||||
await user.update(updateData);
|
await user.update(updateData);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,7 +118,7 @@ export class WorkflowService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'assignment', // CRITICAL: Differentiates from 'spectator_added' - triggers approval request email
|
type: 'assignment', //: Differentiates from 'spectator_added' - triggers approval request email
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true // Approvers need to take action
|
actionRequired: true // Approvers need to take action
|
||||||
});
|
});
|
||||||
@ -506,7 +506,7 @@ export class WorkflowService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber: (workflow as any).requestNumber,
|
requestNumber: (workflow as any).requestNumber,
|
||||||
url: `/request/${(workflow as any).requestNumber}`,
|
url: `/request/${(workflow as any).requestNumber}`,
|
||||||
type: 'assignment', // CRITICAL: This triggers the approval request email notification
|
type: 'assignment', //: This triggers the approval request email notification
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true // Additional approvers need to take action
|
actionRequired: true // Additional approvers need to take action
|
||||||
});
|
});
|
||||||
@ -597,7 +597,7 @@ export class WorkflowService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'spectator_added', // CRITICAL: Differentiates from 'assignment' - triggers spectator added email
|
type: 'spectator_added', //: Differentiates from 'assignment' - triggers spectator added email
|
||||||
priority: 'MEDIUM', // Lower priority than approvers (no action required)
|
priority: 'MEDIUM', // Lower priority than approvers (no action required)
|
||||||
metadata: {
|
metadata: {
|
||||||
addedBy: addedBy // Used in email to show who added the spectator
|
addedBy: addedBy // Used in email to show who added the spectator
|
||||||
@ -3016,7 +3016,7 @@ export class WorkflowService {
|
|||||||
|
|
||||||
if (submissionDate && totalTatHours > 0) {
|
if (submissionDate && totalTatHours > 0) {
|
||||||
// Calculate total elapsed hours by summing elapsed hours from all levels
|
// Calculate total elapsed hours by summing elapsed hours from all levels
|
||||||
// CRITICAL: Only count elapsed hours from completed levels + current active level
|
//: Only count elapsed hours from completed levels + current active level
|
||||||
// Waiting levels (future steps) should contribute 0 elapsed hours
|
// Waiting levels (future steps) should contribute 0 elapsed hours
|
||||||
// This ensures that when in step 1, only step 1's elapsed hours are counted
|
// This ensures that when in step 1, only step 1's elapsed hours are counted
|
||||||
let totalElapsedHours = 0;
|
let totalElapsedHours = 0;
|
||||||
@ -3033,7 +3033,7 @@ export class WorkflowService {
|
|||||||
// Skipped levels don't contribute to elapsed time
|
// Skipped levels don't contribute to elapsed time
|
||||||
continue;
|
continue;
|
||||||
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
|
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
|
||||||
// CRITICAL: Only count elapsed hours for the CURRENT active level
|
//: Only count elapsed hours for the CURRENT active level
|
||||||
// Waiting levels (future steps) should NOT contribute elapsed hours
|
// Waiting levels (future steps) should NOT contribute elapsed hours
|
||||||
// This ensures request-level elapsed time matches the current step's elapsed time
|
// This ensures request-level elapsed time matches the current step's elapsed time
|
||||||
const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber;
|
const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber;
|
||||||
|
|||||||
@ -12,10 +12,11 @@ export const sanitizeHtml = (html: string): string => {
|
|||||||
|
|
||||||
// Custom options can be added here if we need to allow specific tags or attributes
|
// Custom options can be added here if we need to allow specific tags or attributes
|
||||||
// For now, using default options which are quite secure
|
// For now, using default options which are quite secure
|
||||||
|
// Custom options to restrict allowed tags
|
||||||
|
// By NOT spreading ...whiteList, we explicitly only allow what we define
|
||||||
const options = {
|
const options = {
|
||||||
whiteList: {
|
whiteList: {
|
||||||
...whiteList,
|
// Add only specific tags or attributes required by the frontend
|
||||||
// Add any specific tags or attributes required by the frontend
|
|
||||||
'span': ['style', 'class'],
|
'span': ['style', 'class'],
|
||||||
'div': ['style', 'class'],
|
'div': ['style', 'class'],
|
||||||
'p': ['style', 'class'],
|
'p': ['style', 'class'],
|
||||||
@ -35,7 +36,9 @@ export const sanitizeHtml = (html: string): string => {
|
|||||||
'h5': ['style', 'class'],
|
'h5': ['style', 'class'],
|
||||||
'h6': ['style', 'class'],
|
'h6': ['style', 'class'],
|
||||||
'blockquote': ['style', 'class'],
|
'blockquote': ['style', 'class'],
|
||||||
}
|
},
|
||||||
|
stripIgnoreTag: true,
|
||||||
|
stripIgnoreTagBody: ['script']
|
||||||
};
|
};
|
||||||
|
|
||||||
const xssFilter = new FilterXSS(options);
|
const xssFilter = new FilterXSS(options);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ let workingHoursCacheExpiry: Date | null = null;
|
|||||||
*/
|
*/
|
||||||
async function loadWorkingHoursCache(): Promise<void> {
|
async function loadWorkingHoursCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Reload cache every 5 minutes (shorter than holidays since it's more critical)
|
// Reload cache every 5 minutes (shorter than holidays since it's more important)
|
||||||
if (workingHoursCacheExpiry && new Date() < workingHoursCacheExpiry) {
|
if (workingHoursCacheExpiry && new Date() < workingHoursCacheExpiry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -30,7 +30,7 @@ async function loadWorkingHoursCache(): Promise<void> {
|
|||||||
const hours = await getWorkingHours();
|
const hours = await getWorkingHours();
|
||||||
const startDay = await getConfigNumber('WORK_START_DAY', 1); // Monday
|
const startDay = await getConfigNumber('WORK_START_DAY', 1); // Monday
|
||||||
const endDay = await getConfigNumber('WORK_END_DAY', 5); // Friday
|
const endDay = await getConfigNumber('WORK_END_DAY', 5); // Friday
|
||||||
|
|
||||||
workingHoursCache = {
|
workingHoursCache = {
|
||||||
startHour: hours.startHour,
|
startHour: hours.startHour,
|
||||||
endHour: hours.endHour,
|
endHour: hours.endHour,
|
||||||
@ -66,7 +66,7 @@ async function loadHolidaysCache(): Promise<void> {
|
|||||||
const holidays = await holidayService.getHolidaysInRange(startDate, endDate);
|
const holidays = await holidayService.getHolidaysInRange(startDate, endDate);
|
||||||
holidaysCache = new Set(holidays);
|
holidaysCache = new Set(holidays);
|
||||||
holidaysCacheExpiry = dayjs().add(6, 'hour').toDate();
|
holidaysCacheExpiry = dayjs().add(6, 'hour').toDate();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TAT] Error loading holidays:', error);
|
console.error('[TAT] Error loading holidays:', error);
|
||||||
// Continue without holidays if loading fails
|
// Continue without holidays if loading fails
|
||||||
@ -92,7 +92,7 @@ function isWorkingTime(date: Dayjs): boolean {
|
|||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cached working hours (with fallback to TAT_CONFIG)
|
// Use cached working hours (with fallback to TAT_CONFIG)
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
@ -100,25 +100,25 @@ function isWorkingTime(date: Dayjs): boolean {
|
|||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
const day = date.day(); // 0 = Sun, 6 = Sat
|
const day = date.day(); // 0 = Sun, 6 = Sat
|
||||||
const hour = date.hour();
|
const hour = date.hour();
|
||||||
|
|
||||||
// Check if weekend (based on configured working days)
|
// Check if weekend (based on configured working days)
|
||||||
if (day < config.startDay || day > config.endDay) {
|
if (day < config.startDay || day > config.endDay) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if outside working hours (based on configured hours)
|
// Check if outside working hours (based on configured hours)
|
||||||
if (hour < config.startHour || hour >= config.endHour) {
|
if (hour < config.startHour || hour >= config.endHour) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if holiday
|
// Check if holiday
|
||||||
if (isHoliday(date)) {
|
if (isHoliday(date)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,32 +130,32 @@ function isWorkingTime(date: Dayjs): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function addWorkingHours(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
|
export async function addWorkingHours(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
|
||||||
let current = dayjs(start);
|
let current = dayjs(start);
|
||||||
|
|
||||||
// In test mode, convert hours to minutes for faster testing
|
// In test mode, convert hours to minutes for faster testing
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return current.add(hoursToAdd, 'minute');
|
return current.add(hoursToAdd, 'minute');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load working hours and holidays cache if not loaded
|
// Load working hours and holidays cache if not loaded
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
await loadHolidaysCache();
|
await loadHolidaysCache();
|
||||||
|
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
endHour: TAT_CONFIG.WORK_END_HOUR,
|
endHour: TAT_CONFIG.WORK_END_HOUR,
|
||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
// If start time is before working hours or outside working days/holidays,
|
// If start time is before working hours or outside working days/holidays,
|
||||||
// advance to the next working hour start (reset to clean hour)
|
// advance to the next working hour start (reset to clean hour)
|
||||||
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
||||||
const wasOutsideWorkingHours = !isWorkingTime(current);
|
const wasOutsideWorkingHours = !isWorkingTime(current);
|
||||||
|
|
||||||
while (!isWorkingTime(current)) {
|
while (!isWorkingTime(current)) {
|
||||||
const hour = current.hour();
|
const hour = current.hour();
|
||||||
const day = current.day();
|
const day = current.day();
|
||||||
|
|
||||||
// If before work start hour on a working day, jump to work start hour
|
// If before work start hour on a working day, jump to work start hour
|
||||||
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
|
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
|
||||||
current = current.hour(config.startHour);
|
current = current.hour(config.startHour);
|
||||||
@ -164,16 +164,16 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
|
|||||||
current = current.add(1, 'hour');
|
current = current.add(1, 'hour');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If start time was outside working hours, reset to clean work start time (no minutes)
|
// If start time was outside working hours, reset to clean work start time (no minutes)
|
||||||
if (wasOutsideWorkingHours) {
|
if (wasOutsideWorkingHours) {
|
||||||
current = current.minute(0).second(0).millisecond(0);
|
current = current.minute(0).second(0).millisecond(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split into whole hours and fractional part
|
// Split into whole hours and fractional part
|
||||||
const wholeHours = Math.floor(hoursToAdd);
|
const wholeHours = Math.floor(hoursToAdd);
|
||||||
const fractionalHours = hoursToAdd - wholeHours;
|
const fractionalHours = hoursToAdd - wholeHours;
|
||||||
|
|
||||||
let remaining = wholeHours;
|
let remaining = wholeHours;
|
||||||
|
|
||||||
// Add whole hours
|
// Add whole hours
|
||||||
@ -188,7 +188,7 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
|
|||||||
if (fractionalHours > 0) {
|
if (fractionalHours > 0) {
|
||||||
const minutesToAdd = Math.round(fractionalHours * 60);
|
const minutesToAdd = Math.round(fractionalHours * 60);
|
||||||
current = current.add(minutesToAdd, 'minute');
|
current = current.add(minutesToAdd, 'minute');
|
||||||
|
|
||||||
// Check if fractional addition pushed us outside working time
|
// Check if fractional addition pushed us outside working time
|
||||||
if (!isWorkingTime(current)) {
|
if (!isWorkingTime(current)) {
|
||||||
// Advance to next working period
|
// Advance to next working period
|
||||||
@ -196,7 +196,7 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
|
|||||||
current = current.add(1, 'hour');
|
current = current.add(1, 'hour');
|
||||||
const hour = current.hour();
|
const hour = current.hour();
|
||||||
const day = current.day();
|
const day = current.day();
|
||||||
|
|
||||||
// If before work start hour on a working day, jump to work start hour
|
// If before work start hour on a working day, jump to work start hour
|
||||||
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
|
if (day >= config.startDay && day <= config.endDay && !isHoliday(current) && hour < config.startHour) {
|
||||||
current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
|
current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
|
||||||
@ -217,28 +217,28 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
|
|||||||
*/
|
*/
|
||||||
export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
|
export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: number): Promise<Dayjs> {
|
||||||
let current = dayjs(start);
|
let current = dayjs(start);
|
||||||
|
|
||||||
// In test mode, convert hours to minutes for faster testing
|
// In test mode, convert hours to minutes for faster testing
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return current.add(hoursToAdd, 'minute');
|
return current.add(hoursToAdd, 'minute');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load configuration (but don't load holidays - EXPRESS works on holidays too)
|
// Load configuration (but don't load holidays - EXPRESS works on holidays too)
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
|
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
endHour: TAT_CONFIG.WORK_END_HOUR,
|
endHour: TAT_CONFIG.WORK_END_HOUR,
|
||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
// If start time is outside working hours, advance to work start hour (reset to clean hour)
|
// If start time is outside working hours, advance to work start hour (reset to clean hour)
|
||||||
// IMPORTANT: For EXPRESS, we work on ALL days (weekends, holidays), so we don't skip them
|
// IMPORTANT: For EXPRESS, we work on ALL days (weekends, holidays), so we don't skip them
|
||||||
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
||||||
const currentHour = current.hour();
|
const currentHour = current.hour();
|
||||||
const currentDay = current.day(); // 0 = Sunday, 6 = Saturday
|
const currentDay = current.day(); // 0 = Sunday, 6 = Saturday
|
||||||
|
|
||||||
if (currentHour < config.startHour) {
|
if (currentHour < config.startHour) {
|
||||||
// Before work hours - jump to work start hour on the same day (even if weekend/holiday)
|
// Before work hours - jump to work start hour on the same day (even if weekend/holiday)
|
||||||
current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
|
current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
|
||||||
@ -246,23 +246,23 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
|
|||||||
// After work hours - go to next day's work start hour (even if weekend/holiday)
|
// After work hours - go to next day's work start hour (even if weekend/holiday)
|
||||||
current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0);
|
current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split into whole hours and fractional part
|
// Split into whole hours and fractional part
|
||||||
const wholeHours = Math.floor(hoursToAdd);
|
const wholeHours = Math.floor(hoursToAdd);
|
||||||
const fractionalHours = hoursToAdd - wholeHours;
|
const fractionalHours = hoursToAdd - wholeHours;
|
||||||
|
|
||||||
let remaining = wholeHours;
|
let remaining = wholeHours;
|
||||||
let hoursCounted = 0;
|
let hoursCounted = 0;
|
||||||
|
|
||||||
// Add whole hours
|
// Add whole hours
|
||||||
// CRITICAL: For EXPRESS, count ALL days (weekends, holidays) - only check working hours (9 AM - 6 PM)
|
//: For EXPRESS, count ALL days (weekends, holidays) - only check working hours (9 AM - 6 PM)
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
const maxIterations = 10000; // Safety limit
|
const maxIterations = 10000; // Safety limit
|
||||||
|
|
||||||
while (remaining > 0 && iterations < maxIterations) {
|
while (remaining > 0 && iterations < maxIterations) {
|
||||||
current = current.add(1, 'hour');
|
current = current.add(1, 'hour');
|
||||||
const hour = current.hour();
|
const hour = current.hour();
|
||||||
|
|
||||||
// For express: count ALL days (including weekends/holidays)
|
// For express: count ALL days (including weekends/holidays)
|
||||||
// But only during working hours (configured start - end hour)
|
// But only during working hours (configured start - end hour)
|
||||||
// NO checks for day of week or holidays - EXPRESS works 7 days a week
|
// NO checks for day of week or holidays - EXPRESS works 7 days a week
|
||||||
@ -273,16 +273,16 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
|
|||||||
// This ensures we only count 9 AM - 6 PM on any day
|
// This ensures we only count 9 AM - 6 PM on any day
|
||||||
iterations++;
|
iterations++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iterations >= maxIterations) {
|
if (iterations >= maxIterations) {
|
||||||
console.error(`[EXPRESS TAT] Safety break - exceeded ${maxIterations} iterations`);
|
console.error(`[EXPRESS TAT] Safety break - exceeded ${maxIterations} iterations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add fractional part (convert to minutes)
|
// Add fractional part (convert to minutes)
|
||||||
if (fractionalHours > 0) {
|
if (fractionalHours > 0) {
|
||||||
const minutesToAdd = Math.round(fractionalHours * 60);
|
const minutesToAdd = Math.round(fractionalHours * 60);
|
||||||
current = current.add(minutesToAdd, 'minute');
|
current = current.add(minutesToAdd, 'minute');
|
||||||
|
|
||||||
// Check if fractional addition pushed us past working hours
|
// Check if fractional addition pushed us past working hours
|
||||||
if (current.hour() >= config.endHour) {
|
if (current.hour() >= config.endHour) {
|
||||||
// Overflow to next day's working hours (even if weekend/holiday)
|
// Overflow to next day's working hours (even if weekend/holiday)
|
||||||
@ -290,7 +290,7 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
|
|||||||
current = current.add(1, 'day').hour(config.startHour).minute(excessMinutes).second(0).millisecond(0);
|
current = current.add(1, 'day').hour(config.startHour).minute(excessMinutes).second(0).millisecond(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,12 +300,12 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
|
|||||||
*/
|
*/
|
||||||
export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayjs {
|
export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayjs {
|
||||||
let current = dayjs(start);
|
let current = dayjs(start);
|
||||||
|
|
||||||
// In test mode, convert hours to minutes for faster testing
|
// In test mode, convert hours to minutes for faster testing
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return current.add(hoursToAdd, 'minute');
|
return current.add(hoursToAdd, 'minute');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simply add hours without any exclusions (24/7)
|
// Simply add hours without any exclusions (24/7)
|
||||||
return current.add(hoursToAdd, 'hour');
|
return current.add(hoursToAdd, 'hour');
|
||||||
}
|
}
|
||||||
@ -317,12 +317,12 @@ export function addCalendarHours(start: Date | string, hoursToAdd: number): Dayj
|
|||||||
*/
|
*/
|
||||||
export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): Dayjs {
|
export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): Dayjs {
|
||||||
let current = dayjs(start);
|
let current = dayjs(start);
|
||||||
|
|
||||||
// In test mode, convert hours to minutes for faster testing
|
// In test mode, convert hours to minutes for faster testing
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return current.add(hoursToAdd, 'minute');
|
return current.add(hoursToAdd, 'minute');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cached working hours with fallback
|
// Use cached working hours with fallback
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
@ -330,16 +330,16 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
|
|||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
// If start time is before working hours or outside working days,
|
// If start time is before working hours or outside working days,
|
||||||
// advance to the next working hour start (reset to clean hour)
|
// advance to the next working hour start (reset to clean hour)
|
||||||
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
|
||||||
let hour = current.hour();
|
let hour = current.hour();
|
||||||
let day = current.day();
|
let day = current.day();
|
||||||
|
|
||||||
// Check if originally outside working hours
|
// Check if originally outside working hours
|
||||||
const wasOutsideWorkingHours = !(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour);
|
const wasOutsideWorkingHours = !(day >= config.startDay && day <= config.endDay && hour >= config.startHour && hour < config.endHour);
|
||||||
|
|
||||||
// If before work start hour on a working day, jump to work start hour
|
// If before work start hour on a working day, jump to work start hour
|
||||||
if (day >= config.startDay && day <= config.endDay && hour < config.startHour) {
|
if (day >= config.startDay && day <= config.endDay && hour < config.startHour) {
|
||||||
current = current.hour(config.startHour);
|
current = current.hour(config.startHour);
|
||||||
@ -351,12 +351,12 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
|
|||||||
hour = current.hour();
|
hour = current.hour();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If start time was outside working hours, reset to clean work start time
|
// If start time was outside working hours, reset to clean work start time
|
||||||
if (wasOutsideWorkingHours) {
|
if (wasOutsideWorkingHours) {
|
||||||
current = current.minute(0).second(0).millisecond(0);
|
current = current.minute(0).second(0).millisecond(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let remaining = hoursToAdd;
|
let remaining = hoursToAdd;
|
||||||
|
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
@ -364,8 +364,8 @@ export function addWorkingHoursSync(start: Date | string, hoursToAdd: number): D
|
|||||||
const day = current.day();
|
const day = current.day();
|
||||||
const hour = current.hour();
|
const hour = current.hour();
|
||||||
// Simple check without holidays (but respects configured working hours)
|
// Simple check without holidays (but respects configured working hours)
|
||||||
if (day >= config.startDay && day <= config.endDay &&
|
if (day >= config.startDay && day <= config.endDay &&
|
||||||
hour >= config.startHour && hour < config.endHour) {
|
hour >= config.startHour && hour < config.endHour) {
|
||||||
remaining -= 1;
|
remaining -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -388,7 +388,7 @@ export async function initializeHolidaysCache(): Promise<void> {
|
|||||||
export async function clearWorkingHoursCache(): Promise<void> {
|
export async function clearWorkingHoursCache(): Promise<void> {
|
||||||
workingHoursCache = null;
|
workingHoursCache = null;
|
||||||
workingHoursCacheExpiry = null;
|
workingHoursCacheExpiry = null;
|
||||||
|
|
||||||
// Immediately reload the cache with new values
|
// Immediately reload the cache with new values
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
}
|
}
|
||||||
@ -442,36 +442,36 @@ export function calculateDelay(targetDate: Date): number {
|
|||||||
export async function isCurrentlyWorkingTime(priority: string = 'standard'): Promise<boolean> {
|
export async function isCurrentlyWorkingTime(priority: string = 'standard'): Promise<boolean> {
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
await loadHolidaysCache();
|
await loadHolidaysCache();
|
||||||
|
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
|
|
||||||
// In test mode, always working time
|
// In test mode, always working time
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
endHour: TAT_CONFIG.WORK_END_HOUR,
|
endHour: TAT_CONFIG.WORK_END_HOUR,
|
||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
const day = now.day();
|
const day = now.day();
|
||||||
const hour = now.hour();
|
const hour = now.hour();
|
||||||
const dateStr = now.format('YYYY-MM-DD');
|
const dateStr = now.format('YYYY-MM-DD');
|
||||||
|
|
||||||
// Check working hours
|
// Check working hours
|
||||||
const isWorkingHour = hour >= config.startHour && hour < config.endHour;
|
const isWorkingHour = hour >= config.startHour && hour < config.endHour;
|
||||||
|
|
||||||
// For express: include weekends, for standard: exclude weekends
|
// For express: include weekends, for standard: exclude weekends
|
||||||
const isWorkingDay = priority === 'express'
|
const isWorkingDay = priority === 'express'
|
||||||
? true
|
? true
|
||||||
: (day >= config.startDay && day <= config.endDay);
|
: (day >= config.startDay && day <= config.endDay);
|
||||||
|
|
||||||
// Check if not a holiday
|
// Check if not a holiday
|
||||||
const isNotHoliday = !holidaysCache.has(dateStr);
|
const isNotHoliday = !holidaysCache.has(dateStr);
|
||||||
|
|
||||||
return isWorkingDay && isWorkingHour && isNotHoliday;
|
return isWorkingDay && isWorkingHour && isNotHoliday;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -488,16 +488,16 @@ export async function calculateSLAStatus(
|
|||||||
) {
|
) {
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
await loadHolidaysCache();
|
await loadHolidaysCache();
|
||||||
|
|
||||||
const startDate = dayjs(levelStartTime);
|
const startDate = dayjs(levelStartTime);
|
||||||
// Use provided endDate if available (for completed requests), otherwise use current time
|
// Use provided endDate if available (for completed requests), otherwise use current time
|
||||||
const endTime = endDate ? dayjs(endDate) : dayjs();
|
const endTime = endDate ? dayjs(endDate) : dayjs();
|
||||||
|
|
||||||
// Calculate elapsed working hours (with pause handling)
|
// Calculate elapsed working hours (with pause handling)
|
||||||
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority, pauseInfo);
|
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority, pauseInfo);
|
||||||
const remainingHours = Math.max(0, tatHours - elapsedHours);
|
const remainingHours = Math.max(0, tatHours - elapsedHours);
|
||||||
const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0;
|
const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0;
|
||||||
|
|
||||||
// Calculate deadline based on priority
|
// Calculate deadline based on priority
|
||||||
// EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM)
|
// EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM)
|
||||||
// STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM)
|
// STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM)
|
||||||
@ -523,13 +523,13 @@ export async function calculateSLAStatus(
|
|||||||
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
|
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
|
||||||
: (await addWorkingHours(levelStartTime, tatHours)).toDate();
|
: (await addWorkingHours(levelStartTime, tatHours)).toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if currently paused (workflow pause or outside working hours)
|
// Check if currently paused (workflow pause or outside working hours)
|
||||||
// For completed requests (with endDate), it's not paused
|
// For completed requests (with endDate), it's not paused
|
||||||
const isPaused = endDate
|
const isPaused = endDate
|
||||||
? false
|
? false
|
||||||
: (pauseInfo?.isPaused === true || !(await isCurrentlyWorkingTime(priority)));
|
: (pauseInfo?.isPaused === true || !(await isCurrentlyWorkingTime(priority)));
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
|
let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
|
||||||
if (percentageUsed >= 100) {
|
if (percentageUsed >= 100) {
|
||||||
@ -539,22 +539,22 @@ export async function calculateSLAStatus(
|
|||||||
} else if (percentageUsed >= 60) {
|
} else if (percentageUsed >= 60) {
|
||||||
status = 'approaching';
|
status = 'approaching';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format remaining time
|
// Format remaining time
|
||||||
const formatTime = (hours: number) => {
|
const formatTime = (hours: number) => {
|
||||||
if (hours <= 0) return '0h';
|
if (hours <= 0) return '0h';
|
||||||
const days = Math.floor(hours / 8); // 8 working hours per day
|
const days = Math.floor(hours / 8); // 8 working hours per day
|
||||||
const remainingHrs = Math.floor(hours % 8);
|
const remainingHrs = Math.floor(hours % 8);
|
||||||
const minutes = Math.round((hours % 1) * 60);
|
const minutes = Math.round((hours % 1) * 60);
|
||||||
|
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
return minutes > 0
|
return minutes > 0
|
||||||
? `${days}d ${remainingHrs}h ${minutes}m`
|
? `${days}d ${remainingHrs}h ${minutes}m`
|
||||||
: `${days}d ${remainingHrs}h`;
|
: `${days}d ${remainingHrs}h`;
|
||||||
}
|
}
|
||||||
return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`;
|
return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elapsedHours: Math.round(elapsedHours * 100) / 100,
|
elapsedHours: Math.round(elapsedHours * 100) / 100,
|
||||||
remainingHours: Math.round(remainingHours * 100) / 100,
|
remainingHours: Math.round(remainingHours * 100) / 100,
|
||||||
@ -577,26 +577,26 @@ export async function calculateSLAStatus(
|
|||||||
* @returns Elapsed working hours (with decimal precision)
|
* @returns Elapsed working hours (with decimal precision)
|
||||||
*/
|
*/
|
||||||
export async function calculateElapsedWorkingHours(
|
export async function calculateElapsedWorkingHours(
|
||||||
startDate: Date | string,
|
startDate: Date | string,
|
||||||
endDateParam: Date | string | null = null,
|
endDateParam: Date | string | null = null,
|
||||||
priority: string = 'standard',
|
priority: string = 'standard',
|
||||||
pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null }
|
pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null }
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
await loadHolidaysCache();
|
await loadHolidaysCache();
|
||||||
|
|
||||||
// Handle pause: if paused, use elapsed hours at pause time
|
// Handle pause: if paused, use elapsed hours at pause time
|
||||||
if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) {
|
if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) {
|
||||||
// If currently paused, return the elapsed hours at pause time
|
// If currently paused, return the elapsed hours at pause time
|
||||||
// No additional time accumulates while paused
|
// No additional time accumulates while paused
|
||||||
return pauseInfo.pauseElapsedHours;
|
return pauseInfo.pauseElapsedHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If was paused but now resumed, calculate from resume date
|
// If was paused but now resumed, calculate from resume date
|
||||||
let actualStartDate = startDate;
|
let actualStartDate = startDate;
|
||||||
let prePauseElapsed = 0;
|
let prePauseElapsed = 0;
|
||||||
let resumeTime = null;
|
let resumeTime = null;
|
||||||
|
|
||||||
if (pauseInfo?.pauseResumeDate && pauseInfo.pauseElapsedHours !== undefined) {
|
if (pauseInfo?.pauseResumeDate && pauseInfo.pauseElapsedHours !== undefined) {
|
||||||
// Was paused, now resumed
|
// Was paused, now resumed
|
||||||
// Use elapsed hours at pause + time from resume to end
|
// Use elapsed hours at pause + time from resume to end
|
||||||
@ -604,45 +604,45 @@ export async function calculateElapsedWorkingHours(
|
|||||||
actualStartDate = pauseInfo.pauseResumeDate;
|
actualStartDate = pauseInfo.pauseResumeDate;
|
||||||
resumeTime = pauseInfo.pauseResumeDate; // Store resume time for reference
|
resumeTime = pauseInfo.pauseResumeDate; // Store resume time for reference
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = dayjs(actualStartDate);
|
let start = dayjs(actualStartDate);
|
||||||
const end = dayjs(endDateParam || new Date());
|
const end = dayjs(endDateParam || new Date());
|
||||||
|
|
||||||
// In test mode, use raw minutes for 1:1 conversion
|
// In test mode, use raw minutes for 1:1 conversion
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
const postResumeHours = end.diff(start, 'minute') / 60;
|
const postResumeHours = end.diff(start, 'minute') / 60;
|
||||||
return prePauseElapsed + postResumeHours;
|
return prePauseElapsed + postResumeHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
endHour: TAT_CONFIG.WORK_END_HOUR,
|
endHour: TAT_CONFIG.WORK_END_HOUR,
|
||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRITICAL: For resumed levels, we must use the exact resume time as start
|
//: For resumed levels, we must use the exact resume time as start
|
||||||
// Do NOT advance resume time to next working period - resume time is the actual moment TAT resumed
|
// Do NOT advance resume time to next working period - resume time is the actual moment TAT resumed
|
||||||
// Only advance if we're calculating from original start (not resumed)
|
// Only advance if we're calculating from original start (not resumed)
|
||||||
const isResumedLevel = resumeTime !== null;
|
const isResumedLevel = resumeTime !== null;
|
||||||
|
|
||||||
if (!isResumedLevel) {
|
if (!isResumedLevel) {
|
||||||
// Only adjust start time if this is NOT a resumed level
|
// Only adjust start time if this is NOT a resumed level
|
||||||
// For resumed levels, use exact resume time (even if outside working hours)
|
// For resumed levels, use exact resume time (even if outside working hours)
|
||||||
// The working hours calculation below will handle skipping non-working periods
|
// The working hours calculation below will handle skipping non-working periods
|
||||||
|
|
||||||
// CRITICAL FIX: If start time is outside working hours, advance to next working period
|
// FIX: If start time is outside working hours, advance to next working period
|
||||||
// This ensures we only count elapsed time when TAT is actually running
|
// This ensures we only count elapsed time when TAT is actually running
|
||||||
const originalStart = start.format('YYYY-MM-DD HH:mm:ss');
|
const originalStart = start.format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
|
||||||
// For standard priority, check working days and hours
|
// For standard priority, check working days and hours
|
||||||
if (priority !== 'express') {
|
if (priority !== 'express') {
|
||||||
const wasOutsideWorkingHours = !isWorkingTime(start);
|
const wasOutsideWorkingHours = !isWorkingTime(start);
|
||||||
|
|
||||||
while (!isWorkingTime(start)) {
|
while (!isWorkingTime(start)) {
|
||||||
const hour = start.hour();
|
const hour = start.hour();
|
||||||
const day = start.day();
|
const day = start.day();
|
||||||
|
|
||||||
// If before work start hour on a working day, jump to work start hour
|
// If before work start hour on a working day, jump to work start hour
|
||||||
if (day >= config.startDay && day <= config.endDay && !isHoliday(start) && hour < config.startHour) {
|
if (day >= config.startDay && day <= config.endDay && !isHoliday(start) && hour < config.startHour) {
|
||||||
start = start.hour(config.startHour);
|
start = start.hour(config.startHour);
|
||||||
@ -651,7 +651,7 @@ export async function calculateElapsedWorkingHours(
|
|||||||
start = start.add(1, 'hour');
|
start = start.add(1, 'hour');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If start time was outside working hours, reset to clean work start time
|
// If start time was outside working hours, reset to clean work start time
|
||||||
if (wasOutsideWorkingHours) {
|
if (wasOutsideWorkingHours) {
|
||||||
start = start.minute(0).second(0).millisecond(0);
|
start = start.minute(0).second(0).millisecond(0);
|
||||||
@ -669,31 +669,31 @@ export async function calculateElapsedWorkingHours(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For resumed levels, keep the exact resume time - the day-by-day calculation below will handle working hours correctly
|
// For resumed levels, keep the exact resume time - the day-by-day calculation below will handle working hours correctly
|
||||||
|
|
||||||
if (end.isBefore(start)) {
|
if (end.isBefore(start)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalWorkingMinutes = 0;
|
let totalWorkingMinutes = 0;
|
||||||
let currentDate = start.startOf('day');
|
let currentDate = start.startOf('day');
|
||||||
const endDay = end.startOf('day');
|
const endDay = end.startOf('day');
|
||||||
|
|
||||||
// Process each day
|
// Process each day
|
||||||
while (currentDate.isBefore(endDay) || currentDate.isSame(endDay, 'day')) {
|
while (currentDate.isBefore(endDay) || currentDate.isSame(endDay, 'day')) {
|
||||||
const dateStr = currentDate.format('YYYY-MM-DD');
|
const dateStr = currentDate.format('YYYY-MM-DD');
|
||||||
const dayOfWeek = currentDate.day();
|
const dayOfWeek = currentDate.day();
|
||||||
|
|
||||||
// Check if this day is a working day
|
// Check if this day is a working day
|
||||||
const isWorkingDay = priority === 'express'
|
const isWorkingDay = priority === 'express'
|
||||||
? true
|
? true
|
||||||
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
|
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
|
||||||
const isNotHoliday = !holidaysCache.has(dateStr);
|
const isNotHoliday = !holidaysCache.has(dateStr);
|
||||||
|
|
||||||
if (isWorkingDay && isNotHoliday) {
|
if (isWorkingDay && isNotHoliday) {
|
||||||
// Determine the working period for this day
|
// Determine the working period for this day
|
||||||
let dayStart = currentDate.hour(config.startHour).minute(0).second(0);
|
let dayStart = currentDate.hour(config.startHour).minute(0).second(0);
|
||||||
let dayEnd = currentDate.hour(config.endHour).minute(0).second(0);
|
let dayEnd = currentDate.hour(config.endHour).minute(0).second(0);
|
||||||
|
|
||||||
// Adjust for first day (might start mid-day)
|
// Adjust for first day (might start mid-day)
|
||||||
if (currentDate.isSame(start, 'day')) {
|
if (currentDate.isSame(start, 'day')) {
|
||||||
if (start.hour() >= config.endHour) {
|
if (start.hour() >= config.endHour) {
|
||||||
@ -706,7 +706,7 @@ export async function calculateElapsedWorkingHours(
|
|||||||
}
|
}
|
||||||
// If before work hours, dayStart is already correct (work start time)
|
// If before work hours, dayStart is already correct (work start time)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust for last day (might end mid-day)
|
// Adjust for last day (might end mid-day)
|
||||||
if (currentDate.isSame(end, 'day')) {
|
if (currentDate.isSame(end, 'day')) {
|
||||||
if (end.hour() < config.startHour) {
|
if (end.hour() < config.startHour) {
|
||||||
@ -719,25 +719,25 @@ export async function calculateElapsedWorkingHours(
|
|||||||
}
|
}
|
||||||
// If after work hours, dayEnd is already correct (work end time)
|
// If after work hours, dayEnd is already correct (work end time)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate minutes worked this day
|
// Calculate minutes worked this day
|
||||||
if (dayStart.isBefore(dayEnd)) {
|
if (dayStart.isBefore(dayEnd)) {
|
||||||
const minutesThisDay = dayEnd.diff(dayStart, 'minute');
|
const minutesThisDay = dayEnd.diff(dayStart, 'minute');
|
||||||
totalWorkingMinutes += minutesThisDay;
|
totalWorkingMinutes += minutesThisDay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = currentDate.add(1, 'day');
|
currentDate = currentDate.add(1, 'day');
|
||||||
|
|
||||||
// Safety check
|
// Safety check
|
||||||
if (currentDate.diff(start, 'day') > 730) { // 2 years
|
if (currentDate.diff(start, 'day') > 730) { // 2 years
|
||||||
console.error('[TAT] Safety break - exceeded 2 years');
|
console.error('[TAT] Safety break - exceeded 2 years');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = totalWorkingMinutes / 60;
|
const hours = totalWorkingMinutes / 60;
|
||||||
|
|
||||||
// Add pre-pause elapsed hours if resumed
|
// Add pre-pause elapsed hours if resumed
|
||||||
return prePauseElapsed + hours;
|
return prePauseElapsed + hours;
|
||||||
}
|
}
|
||||||
@ -757,51 +757,51 @@ export async function calculateBusinessDays(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
await loadWorkingHoursCache();
|
await loadWorkingHoursCache();
|
||||||
await loadHolidaysCache();
|
await loadHolidaysCache();
|
||||||
|
|
||||||
let start = dayjs(startDate).startOf('day');
|
let start = dayjs(startDate).startOf('day');
|
||||||
const end = dayjs(endDate || new Date()).startOf('day');
|
const end = dayjs(endDate || new Date()).startOf('day');
|
||||||
|
|
||||||
// In test mode, use calendar days
|
// In test mode, use calendar days
|
||||||
if (isTestMode()) {
|
if (isTestMode()) {
|
||||||
return end.diff(start, 'day') + 1;
|
return end.diff(start, 'day') + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workingHoursCache || {
|
const config = workingHoursCache || {
|
||||||
startHour: TAT_CONFIG.WORK_START_HOUR,
|
startHour: TAT_CONFIG.WORK_START_HOUR,
|
||||||
endHour: TAT_CONFIG.WORK_END_HOUR,
|
endHour: TAT_CONFIG.WORK_END_HOUR,
|
||||||
startDay: TAT_CONFIG.WORK_START_DAY,
|
startDay: TAT_CONFIG.WORK_START_DAY,
|
||||||
endDay: TAT_CONFIG.WORK_END_DAY
|
endDay: TAT_CONFIG.WORK_END_DAY
|
||||||
};
|
};
|
||||||
|
|
||||||
let businessDays = 0;
|
let businessDays = 0;
|
||||||
let current = start;
|
let current = start;
|
||||||
|
|
||||||
// Count each day from start to end (inclusive)
|
// Count each day from start to end (inclusive)
|
||||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
||||||
const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday
|
const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday
|
||||||
const dateStr = current.format('YYYY-MM-DD');
|
const dateStr = current.format('YYYY-MM-DD');
|
||||||
|
|
||||||
// For express priority: count all days (including weekends) but exclude holidays
|
// For express priority: count all days (including weekends) but exclude holidays
|
||||||
// For standard priority: count only working days (Mon-Fri) and exclude holidays
|
// For standard priority: count only working days (Mon-Fri) and exclude holidays
|
||||||
const isWorkingDay = priority === 'express'
|
const isWorkingDay = priority === 'express'
|
||||||
? true // Express includes weekends
|
? true // Express includes weekends
|
||||||
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
|
: (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay);
|
||||||
|
|
||||||
const isNotHoliday = !holidaysCache.has(dateStr);
|
const isNotHoliday = !holidaysCache.has(dateStr);
|
||||||
|
|
||||||
if (isWorkingDay && isNotHoliday) {
|
if (isWorkingDay && isNotHoliday) {
|
||||||
businessDays++;
|
businessDays++;
|
||||||
}
|
}
|
||||||
|
|
||||||
current = current.add(1, 'day');
|
current = current.add(1, 'day');
|
||||||
|
|
||||||
// Safety check to prevent infinite loops
|
// Safety check to prevent infinite loops
|
||||||
if (current.diff(start, 'day') > 730) { // 2 years
|
if (current.diff(start, 'day') > 730) { // 2 years
|
||||||
console.error('[TAT] Safety break - exceeded 2 years in business days calculation');
|
console.error('[TAT] Safety break - exceeded 2 years in business days calculation');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return businessDays;
|
return businessDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user